diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 00000000..5172429f
Binary files /dev/null and b/.DS_Store differ
diff --git a/.cursor/checklists/breakdown-checklist.md b/.cursor/checklists/breakdown-checklist.md
new file mode 100644
index 00000000..d4ade037
--- /dev/null
+++ b/.cursor/checklists/breakdown-checklist.md
@@ -0,0 +1,18 @@
+## ✅ Self-Check Checklist
+
+| 항목 | 설명 | 예시 | Pass 기준 |
+| ---------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------- | -------------------------------- |
+| **1. 사용자 행동이 포함되어 있는가?** | Flow 이름이 클릭, 선택, 입력 등으로 시작해야 함 | “반복 설정을 클릭하면 옵션이 표시된다” | 모든 Flow는 사용자 행동으로 시작 |
+| **2. 기대 결과가 명확한가?** | 결과가 “표시된다”, “저장된다” 등으로 끝나야 함 | “옵션 목록이 표시된다” | 모든 Flow는 결과 문장으로 끝 |
+| **3. 단일 목적 Flow인가?** | 하나의 Flow가 여러 동작/결과를 포함하지 않음 | ✅ “클릭 시 옵션 표시” ❌ “클릭 후 저장까지” | Flow는 단일 목적 |
+| **4. 예외 조건이 분리되어 있는가?** | 조건 분기(31일, 윤년 등)는 별도 Flow로 분리 | “윤년 29일 선택 시” | 모든 예외는 독립 Flow |
+| **5. 정상/예외 Flow 구분 명확한가?** | Flow Type 필드에 Normal/Exception 명시 | Flow 1-1 Normal / 1-2 Exception | 모두 구분됨 |
+| **6. Flow 이름이 행동+결과 형태인가?** | “~시 ~된다” 형식 | “반복 클릭 시 옵션 표시” | 이름만으로 의도 파악 가능 |
+| **7. Flow 중복 없는가?** | 동일한 Input/Trigger/Output 조합이 중복되지 않음 | “옵션 표시” Flow 한 번만 존재 | 중복 Flow 없음 |
+| **8. PRD의 Acceptance Criteria와 매핑되는가?** | PRD의 요구사항 문장마다 Flow 존재 | “윤년 처리 규칙 적용” → Flow 1-3 | 모든 AC가 Flow로 표현됨 |
+| **9. UI 피드백 Flow 존재하는가?** | UI 변화는 별도 Flow로 분리 | “반복 일정 hover 시 툴팁 표시” | 존재 |
+| **10. 내부 로직 언급 없이 사용자 관점인가?** | DB나 함수 호출 등 내부 처리 언급 없음 | “저장 시 반복 규칙이 적용됨” | Flow는 사용자 중심 |
+| **11. Flow 순서가 논리적으로 자연스러운가?** | Flow ID 순서가 UX 순서와 일치 | 옵션 표시 → 옵션 선택 → 저장 결과 | 순서상 자연스러움 |
+| **12. I/O 정의가 완전한가?** | Input, Trigger, Output 3요소 모두 존재 | Input: 날짜 선택 / Trigger: 클릭 / Output: 옵션 표시 | 모든 Flow 완전 |
+
+---
diff --git a/.cursor/checklists/how-to-design-test.md b/.cursor/checklists/how-to-design-test.md
new file mode 100644
index 00000000..4636b5da
--- /dev/null
+++ b/.cursor/checklists/how-to-design-test.md
@@ -0,0 +1,169 @@
+## 1. 테스트는 명세처럼 읽혀야 한다
+
+- 테스트는 “코드 검증”이 아니라 “명세 문서”처럼 읽혀야 한다.
+- 테스트 이름은 **행동 + 기대 결과** 형식으로 작성한다.
+ ```
+ it('잘못된 비밀번호 입력 시 에러 메시지를 표시한다', ...)
+ it('반복 일정 생성 시 아이콘이 표시된다', ...)
+
+ ```
+- 내부 로직보다 **사용자 관점의 결과**에 초점을 맞춘다.
+
+---
+
+## 2. AAA 패턴(Arrange – Act – Assert)을 따른다
+
+테스트의 기본 구조는 다음과 같다.
+
+```
+1. Arrange (준비): 테스트 환경, 입력값, mock 세팅
+2. Act (실행): 테스트할 실제 동작 수행
+3. Assert (검증): 기대 결과 확인
+
+```
+
+예시:
+
+```tsx
+it('유효한 이메일과 비밀번호로 로그인하면 성공 메시지를 표시한다', async () => {
+ // Arrange
+ render();
+ await user.type(screen.getByLabelText('이메일'), 'user@example.com');
+ await user.type(screen.getByLabelText('비밀번호'), '1234');
+
+ // Act
+ await user.click(screen.getByText('로그인'));
+
+ // Assert
+ expect(await screen.findByText('환영합니다')).toBeInTheDocument();
+});
+```
+
+- 이 순서를 지키면 테스트가 문서처럼 읽히고, 디버깅이 쉬워진다.
+- `beforeEach`는 **Arrange 일부를 공통화**하는 용도로만 사용한다.
+
+---
+
+## 3. 하나의 테스트는 하나의 목적만 검증한다
+
+- 각 테스트(`it`)는 **하나의 개념적 목적만** 검증해야 한다.
+- 여러 개의 `expect`를 써도 좋지만, 모두 같은 시나리오 내에서만 사용한다.
+- 서로 다른 기능이나 흐름을 한 테스트에 섞지 않는다.
+
+---
+
+## 4. 테스트 간 독립성을 보장한다
+
+- 테스트 실행 순서가 달라져도 결과가 같아야 한다.
+- `beforeEach`로 공통 세팅을 하되, 상태 공유는 금지한다.
+- Mock, DOM, 전역 상태는 테스트마다 새로 생성한다.
+
+---
+
+## 5. Mocking은 최소한으로만 사용한다
+
+- Mock은 외부 의존(API, DB, 네트워크 등)에 한정한다.
+- Mock이 많을수록 실제 코드 대신 Mock을 테스트하게 된다.
+- 가능한 실제 로직을 실행하고, side effect가 있는 부분만 Mock한다.
+
+---
+
+## 6. 입력과 출력이 명확한 단위를 테스트한다
+
+- “예측 가능한 결과를 내는 함수(순수 함수)”는 반드시 단위 테스트한다.
+- 조건 분기, 계산, 변환 로직은 단위 테스트 대상이다.
+- DOM, 상태관리, API 통신 등 외부 의존이 포함된 로직은 통합 테스트로 다룬다.
+
+---
+
+## 7. 테스트 이름과 구조는 일관되어야 한다
+
+| 구분 | 규칙 | 예시 |
+| ---------- | ---------------------- | --------------------------------------------------- |
+| 파일명 | `{feature}.spec.tsx` | `login.spec.tsx` |
+| `describe` | `"기능명 Feature"` | `"Login Feature"` |
+| `it` | `"TC-01 - 시나리오명"` | `"TC-01 - 유효한 로그인 시 성공 메시지를 표시한다"` |
+
+---
+
+## 8. 불필요한 `waitFor`, `act` 사용을 피한다
+
+- 명확한 이벤트(클릭, 입력 등) 이후에만 `waitFor`를 사용한다.
+- 타이밍 보정용 `waitFor`는 불안정하고 느린 테스트를 만든다.
+
+---
+
+## 9. Magic number와 하드코딩된 문자열을 피한다
+
+- 의미 없는 숫자나 문자열은 상수나 변수명으로 명확히 표현한다.
+ ```tsx
+ const INVALID_PASSWORD = 'wrong_password';
+ const VALID_EMAIL = 'user@example.com';
+ ```
+
+---
+
+## 10. UI 테스트는 사용자 행동 기반으로 작성한다
+
+- React Testing Library의 철학: “사용자가 보는 화면을 그대로 테스트하라.”
+- DOM 구조나 내부 구현이 아니라 **사용자 행동과 결과**를 검증한다.
+ ```tsx
+ await user.type(screen.getByLabelText('이메일'), 'user@example.com');
+ await user.click(screen.getByText('로그인'));
+ expect(await screen.findByText('환영합니다')).toBeInTheDocument();
+ ```
+
+---
+
+## 11. 테스트는 실패했을 때 원인을 바로 파악할 수 있어야 한다
+
+- 실패 메시지를 통해 “무엇이 잘못됐는지” 바로 알 수 있어야 한다.
+- helper나 abstraction이 많으면 의도가 흐려지므로 피한다.
+- 테스트는 독립적으로 읽히는 문장처럼 구성한다.
+
+---
+
+## 12. 테스트 코드도 리팩토링 가능한 코드다
+
+- 테스트는 제품 코드와 동일한 수준의 품질로 관리해야 한다.
+- 반복되는 setup은 공통화하되, 가독성이 우선이다.
+- DRY(Don’t Repeat Yourself)보다 “의도가 드러나는 중복”이 더 낫다.
+
+---
+
+## 13. 단위 테스트와 통합 테스트의 역할을 구분한다
+
+| 구분 | 목적 | 특징 | 예시 |
+| ------------------------ | --------------------- | -------------- | ---------------------- |
+| 단위(Unit) 테스트 | 순수 로직 검증 | 빠르고 독립적 | 날짜 계산, 유효성 검사 |
+| 통합(Integration) 테스트 | 로직 간 상호작용 검증 | 실제 흐름 중심 | 컴포넌트 + API 호출 |
+
+---
+
+## 14. 좋은 테스트의 3대 원칙
+
+| 원칙 | 설명 | 목표 |
+| -------- | ----------------------------- | ------------------ |
+| Fast | 빠르게 실행되어야 한다 | 테스트 피드백 속도 |
+| Isolated | 다른 테스트에 영향받지 않는다 | 순서 의존 제거 |
+| Readable | 누구나 의도를 이해할 수 있다 | 문서처럼 읽힘 |
+
+---
+
+## 15. Kent Beck의 테스트 철학을 따른다
+
+- 테스트는 명확하고 간결해야 한다.
+- 한 테스트는 하나의 의도만 가져야 한다.
+- 실행 순서나 내부 구현에 의존하지 않는다.
+- 불필요한 Mock, setup, 중복을 제거한다.
+- 테스트는 “설계 품질의 피드백 루프”다.
+
+---
+
+## 16. 테스트는 제품 품질의 일부다
+
+- 테스트는 버그를 찾기 위한 도구가 아니라 **설계를 검증하는 문서**다.
+- TDD로 작성된 테스트는 더 나은 설계를 이끌어낸다.
+- 좋은 테스트는 유지보수 속도와 신뢰도를 높인다.
+
+---
diff --git a/.cursor/checklists/how-to-test.md b/.cursor/checklists/how-to-test.md
new file mode 100644
index 00000000..c1ef0665
--- /dev/null
+++ b/.cursor/checklists/how-to-test.md
@@ -0,0 +1,78 @@
+Always follow the instructions in plan.md. When I say "go", find the next unmarked test in plan.md, implement the test, then implement only enough code to make that test pass.
+
+# ROLE AND EXPERTISE
+
+You are a senior software engineer who follows Kent Beck's Test-Driven Development (TDD) and Tidy First principles. Your purpose is to guide development following these methodologies precisely.
+
+# CORE DEVELOPMENT PRINCIPLES
+
+- Always follow the TDD cycle: Red → Green → Refactor
+- Write the simplest failing test first
+- Implement the minimum code needed to make tests pass
+- Refactor only after tests are passing
+- Follow Beck's "Tidy First" approach by separating structural changes from behavioral changes
+- Maintain high code quality throughout development
+
+# TDD METHODOLOGY GUIDANCE
+
+- Start by writing a failing test that defines a small increment of functionality
+- Use meaningful test names that describe behavior (e.g., "shouldSumTwoPositiveNumbers")
+- Make test failures clear and informative
+- Write just enough code to make the test pass - no more
+- Once tests pass, consider if refactoring is needed
+- Repeat the cycle for new functionality
+
+# TIDY FIRST APPROACH
+
+- Separate all changes into two distinct types:
+ 1. STRUCTURAL CHANGES: Rearranging code without changing behavior (renaming, extracting methods, moving code)
+ 2. BEHAVIORAL CHANGES: Adding or modifying actual functionality
+- Never mix structural and behavioral changes in the same commit
+- Always make structural changes first when both are needed
+- Validate structural changes do not alter behavior by running tests before and after
+
+# COMMIT DISCIPLINE
+
+- Only commit when:
+ 1. ALL tests are passing
+ 2. ALL compiler/linter warnings have been resolved
+ 3. The change represents a single logical unit of work
+ 4. Commit messages clearly state whether the commit contains structural or behavioral changes
+- Use small, frequent commits rather than large, infrequent ones
+
+# CODE QUALITY STANDARDS
+
+- Eliminate duplication ruthlessly
+- Express intent clearly through naming and structure
+- Make dependencies explicit
+- Keep methods small and focused on a single responsibility
+- Minimize state and side effects
+- Use the simplest solution that could possibly work
+
+# REFACTORING GUIDELINES
+
+- Refactor only when tests are passing (in the "Green" phase)
+- Use established refactoring patterns with their proper names
+- Make one refactoring change at a time
+- Run tests after each refactoring step
+- Prioritize refactorings that remove duplication or improve clarity
+
+# EXAMPLE WORKFLOW
+
+When approaching a new feature:
+
+1. Write a simple failing test for a small part of the feature
+2. Implement the bare minimum to make it pass
+3. Run tests to confirm they pass (Green)
+4. Make any necessary structural changes (Tidy First), running tests after each change
+5. Commit structural changes separately
+6. Add another test for the next small increment of functionality
+7. Repeat until the feature is complete, committing behavioral changes separately from structural ones
+
+Follow this process precisely, always prioritizing clean, well-tested code over quick implementation.
+
+Always write one test at a time, make it run, then improve structure. Always run all the tests (except long-running tests) each time.
+
+# TYPESCRIPT-SPECIFIC
+
+Update the rules to fit TypeScript.
diff --git a/.cursor/checklists/integration-test-quality.md b/.cursor/checklists/integration-test-quality.md
new file mode 100644
index 00000000..1791b7c1
--- /dev/null
+++ b/.cursor/checklists/integration-test-quality.md
@@ -0,0 +1,101 @@
+# ✅ Integration Test Quality Checklist
+
+**목적:**
+AI 또는 사람이 작성한 통합 테스트 코드가
+명세 기반·사용자 중심·유지보수 가능한 수준으로 작성되었는지 평가하기 위한 품질 기준입니다.
+
+---
+
+## 🧭 1. 테스트 의도와 시나리오 일치성
+
+| 항목 | 설명 | Pass 기준 | 상태 |
+| -------------------------- | --------------------------------------------------------------- | ------------------------------------ | ---- |
+| **1.1 Flow 매핑 정확성** | 각 `it()` 테스트가 입력 문서의 Flow ID/Name과 정확히 대응하는가 | `it('2-1-1 - ...')` 형식으로 ID 일치 | |
+| **1.2 시나리오 재현성** | 테스트가 Input → Trigger → Output 순서를 따르는가 | 주석/코드로 순서 명시 | |
+| **1.3 비즈니스 의도 일치** | 단순 DOM 확인이 아니라 사용자 행동 결과를 검증하는가 | 클릭 → 렌더링 결과 확인 | |
+
+---
+
+## 🧩 2. 테스트 구조 및 가독성
+
+| 항목 | 설명 | Pass 기준 | 상태 |
+| -------------------------- | ----------------------------------------------------------- | ---------------------------------------- | ---- |
+| **2.1 구조 계층화** | Epic → Story → Flow 구조가 `describe` 계층으로 구현되었는가 | `describe('Story') → it('Flow')` | |
+| **2.2 테스트 이름 명확성** | `it()` 이름이 동작 중심 문장 형태로 되어 있는가 | 예: “반복 일정이 아이콘과 함께 표시된다” | |
+| **2.3 AAA 패턴 분리** | Arrange / Act / Assert 단계가 구분되어 있는가 | 빈 줄 또는 주석으로 분리 | |
+| **2.4 중복 제거** | setup, mock, render 코드가 공통화되어 있는가 | `beforeEach`로 처리 | |
+| **2.5 주석 품질** | “무엇을 하는가”보다 “왜 하는가”를 설명하는 주석인가 | 의도 중심 주석만 존재 | |
+
+---
+
+## 🧪 3. 테스트 행위(Behavior) 품질
+
+| 항목 | 설명 | Pass 기준 | 상태 |
+| ------------------------------- | ------------------------------------------------------------------ | ----------------------- | ---- |
+| **3.1 사용자 이벤트 기반 검증** | `userEvent`로 클릭·입력 등 사용자 행위를 재현했는가 | 직접 DOM 조작 금지 | |
+| **3.2 비동기 안정성 확보** | `await screen.findBy...` 또는 `waitFor` 사용으로 타이밍 제어했는가 | 즉시 `getBy` 검증 없음 | |
+| **3.3 접근성 선택자 사용** | `aria-label`, `role`, `alt`, `labelText`로 요소 탐색했는가 | `getByText`만 사용 금지 | |
+| **3.4 상태 변화 검증** | 내부 state가 아니라 UI 결과를 검증했는가 | state 직접 참조 없음 | |
+| **3.5 예외/부재 검증 포함** | 조건부 UI(예: “아이콘 없음”)을 `queryBy`로 검증했는가 | 정상/예외 모두 커버 | |
+
+---
+
+## 🧱 4. 코드 품질 및 안정성
+
+| 항목 | 설명 | Pass 기준 | 상태 |
+| --------------------- | ----------------------------------------- | ------------------------------ | ---- |
+| **4.1 비결정성 제거** | 랜덤값·시간 의존 로직이 없는가 | `Math.random`, `Date.now` 없음 | |
+| **4.2 Mock 일관성** | mock/stub이 일관된 형태로 정의되어 있는가 | `vi.fn()` 혹은 axios mock 통일 | |
+| **4.3 테스트 독립성** | 테스트 간 전역 상태 공유 없이 동작하는가 | `beforeEach`로 상태 초기화 | |
+| **4.4 Flaky 방지** | `waitFor` 타임아웃, queryBy로 안정성 확보 | rerun 시 동일 결과 | |
+
+---
+
+## 🧠 5. 테스트의 명세적 역할 (Kent Beck 기준)
+
+| 항목 | 설명 | Pass 기준 | 상태 |
+| ---------------------- | ---------------------------------------------- | -------------------------- | ---- |
+| **5.1 의도 명확성** | 테스트가 “무엇을 증명하는가” 명확히 드러나는가 | 문서처럼 읽힘 | |
+| **5.2 최소 검증 원칙** | 하나의 테스트가 하나의 결과만 검증하는가 | 독립된 `expect`만 존재 | |
+| **5.3 리팩터링 내성** | UI 구조 변경에도 검증 의미 유지되는가 | 라벨 기반 검증 사용 | |
+| **5.4 유지보수성** | 새 Flow 추가 시 구조 확장 용이한가 | Story별 describe 구조 유지 | |
+
+---
+
+## 🧾 6. 메타 품질 (자동화 평가용)
+
+| 항목 | 설명 | Pass 기준 | 상태 |
+| ---------------------- | ------------------------------------------- | -------------------------------- | ---- |
+| **6.1 Flow 커버리지** | 문서의 모든 Flow가 테스트로 작성되었는가 | 누락된 ID 없음 | |
+| **6.2 코드 길이 균형** | 각 테스트가 과도하게 길거나 복잡하지 않은가 | 20~40줄 내외 | |
+| **6.3 실행 안정성** | 3회 이상 반복 실행 시 동일 결과인가 | flaky rate 0 | |
+| **6.4 네이밍 일관성** | 파일명, describe명, it명 패턴 일관성 유지 | `{feature}-integration.spec.tsx` | |
+
+---
+
+## 📊 평가 요약
+
+| 카테고리 | 가중치 | 평가 포인트 |
+| ------------------- | ------ | ------------------------------- |
+| **시나리오 일치성** | 25% | Flow 재현, 목적 명확 |
+| **구조/가독성** | 20% | AAA 구조, 네이밍 일관 |
+| **행위 품질** | 25% | 사용자 이벤트 중심, 접근성 검증 |
+| **코드 안정성** | 15% | mock 일관성, flaky 방지 |
+| **명세로서의 품질** | 15% | 문서처럼 읽힘, 유지보수성 |
+
+> **총점 100점 중 80점 이상: Good Integration Test
+> 90점 이상: Excellent Integration Test**
+
+---
+
+## 💬 사용 예시
+
+```bash
+[✅] Flow ID가 모두 매칭된다 (1.1)
+[✅] UI 렌더링 후 findBy로 검증 (3.2)
+[✅] 반복 일정 아이콘 aria-label로 검증 (3.3)
+[⚠️] queryByText 부재 검증 누락 (3.5)
+[✅] beforeEach로 render 공통화 (2.4)
+[❌] waitFor 누락 (3.2)
+총점: 88점 → Excellent 👍
+```
diff --git a/.cursor/checklists/kent-beck-test.md b/.cursor/checklists/kent-beck-test.md
new file mode 100644
index 00000000..046fa650
--- /dev/null
+++ b/.cursor/checklists/kent-beck-test.md
@@ -0,0 +1,296 @@
+## Common mistakes with React Testing Library
+
+Hi there 👋 I created React Testing Library because I wasn't satisfied with the testing landscape at the time. It expanded to DOM Testing Library and now we have Testing Library implementations (wrappers) for every popular JavaScript framework and testing tool that targets the DOM (and even some that don't).
+
+As time has gone on, we've made some small changes to the API and we've discovered suboptimal patterns. Despite our efforts to document the "better way" to use the utilities we provide, I still see blog posts and tests written following these suboptimal patterns and I'd like to go through some of these, explain why they're not great and how you can improve your tests to avoid these pitfalls.
+
+Note: I label each of these by their importance:
+
+low: this is mostly just my opinion, feel free to ignore and you'll probably be fine.
+medium: you might experience bugs, lose confidence, or be doing work you don't need to
+high: definitely listen to this advice! You're likely missing confidence or will have problematic tests
+Not using Testing Library ESLint plugins
+Importance: medium
+If you'd like to avoid several of these common mistakes, then the official ESLint plugins could help out a lot:
+
+eslint-plugin-testing-library
+eslint-plugin-jest-dom
+Note: If you are using create-react-app, eslint-plugin-testing-library is already included as a dependency.
+
+Advice: Install and use the ESLint plugin for Testing Library.
+
+Using wrapper as the variable name for the return value from render
+Importance: low
+// ❌
+const wrapper = render()
+wrapper.rerender()
+
+// ✅
+const { rerender } = render()
+rerender()
+The name wrapper is old cruft from enzyme and we don't need that here. The return value from render is not "wrapping" anything. It's simply a collection of utilities that (thanks to the next thing) you should actually not often need anyway.
+
+Advice: destructure what you need from render or call it view.
+Using cleanup
+Importance: medium
+// ❌
+import { render, screen, cleanup } from '@testing-library/react'
+
+afterEach(cleanup)
+
+// ✅
+import { render, screen } from '@testing-library/react'
+For a long time now cleanup happens automatically (supported for most major testing frameworks) and you no longer need to worry about it. Learn more.
+
+Advice: don't use cleanup
+Not using screen
+Importance: medium
+// ❌
+const { getByRole } = render()
+const errorMessageNode = getByRole('alert')
+
+// ✅
+render()
+const errorMessageNode = screen.getByRole('alert')
+screen was added in DOM Testing Library v6.11.0 (which means you should have access to it in @testing-library/react@>=9). It comes from the same import statement you get render from:
+
+import { render, screen } from '@testing-library/react'
+The benefit of using screen is you no longer need to keep the render call destructure up-to-date as you add/remove the queries you need. You only need to type screen. and let your editor's magic autocomplete take care of the rest.
+
+The only exception to this is if you're setting the container or baseElement which you probably should avoid doing (I honestly can't think of a legitimate use case for those options anymore and they only exist for historical reasons at this point).
+
+You can also call screen.debug instead of debug
+
+Advice: use screen for querying and debugging.
+Using the wrong assertion
+Importance: high
+const button = screen.getByRole('button', { name: /disabled button/i })
+
+// ❌
+expect(button.disabled).toBe(true)
+// error message:
+// expect(received).toBe(expected) // Object.is equality
+//
+// Expected: true
+// Received: false
+
+// ✅
+expect(button).toBeDisabled()
+// error message:
+// Received element is not disabled:
+//
+That toBeDisabled assertion comes from jest-dom. It's strongly recommended to use jest-dom because the error messages you get with it are much better.
+
+Advice: install and use @testing-library/jest-dom\*\*
+
+Wrapping things in act unnecessarily
+Importance: medium
+// ❌
+act(() => {
+render()
+})
+
+const input = screen.getByRole('textbox', { name: /choose a fruit/i })
+act(() => {
+fireEvent.keyDown(input, { key: 'ArrowDown' })
+})
+
+// ✅
+render()
+const input = screen.getByRole('textbox', { name: /choose a fruit/i })
+fireEvent.keyDown(input, { key: 'ArrowDown' })
+I see people wrapping things in act like this because they see these "act" warnings all the time and are just desperately trying anything they can to get them to go away, but what they don't know is that render and fireEvent are already wrapped in act! So those are doing nothing useful.
+
+Most of the time, if you're seeing an act warning, it's not just something to be silenced, but it's actually telling you that something unexpected is happening in your test. You can learn more about this from my blog post (and videos): Fix the "not wrapped in act(...)" warning.
+
+Advice: Learn when act is necessary and don't wrap things in act unnecessarily.
+
+Using the wrong query
+Importance: high
+// ❌
+// assuming you've got this DOM to work with:
+//
+screen.getByTestId('username')
+
+// ✅
+// change the DOM to be accessible by associating the label and setting the type
+//
+screen.getByRole('textbox', { name: /username/i })
+We maintain a page called "Which query should I use?" of the queries you should attempt to use in the order you should attempt to use them. If your goal is aligned with ours of having tests that give you confidence that your app will work when your users use them, then you'll want to query the DOM as closely to the way your end-users do so as possible. The queries we provide will help you to do this, but not all queries are created equally.
+
+Using container to query for elements
+As a sub-section of "Using the wrong query" I want to talk about querying on the container directly.
+
+// ❌
+const { container } = render()
+const button = container.querySelector('.btn-primary')
+expect(button).toHaveTextContent(/click me/i)
+
+// ✅
+render()
+screen.getByRole('button', { name: /click me/i })
+We want to ensure that your users can interact with your UI and if you query around using querySelector we lose a lot of that confidence, the test is harder to read, and it will break more frequently. This goes hand-in-hand with the next sub-section:
+
+Not querying by text
+As a sub-section of "Using the wrong query", I want to talk about why I recommend you query by the actual text (in the case of localization, I recommend the default locale), rather than using test IDs or other mechanisms everywhere.
+
+// ❌
+screen.getByTestId('submit-button')
+
+// ✅
+screen.getByRole('button', { name: /submit/i })
+If you don't query by the actual text, then you have to do extra work to make sure that your translations are getting applied correctly. The biggest complaint I hear about this is that it leads to content writers breaking your tests. My rebuttal to that is that first, if a content writer changes "Username" to "Email" that's a change I definitely want to know about (because I'll need to change my implementation). Also, if there is a situation where they break something, fixing that issue takes no time at all. It's easy to triage and easy to fix.
+
+So the cost is pretty low, and the benefit is you get increased confidence that your translations are applied correctly and your tests are easier to write and read.
+
+I should mention that not everyone agrees with me on this, feel free to read more about it in this tweet thread.
+
+Not using *ByRole most of the time
+As a sub-section of "Using the wrong query" I want to talk about *ByRole. In recent versions, the \*ByRole queries have been seriously improved (primarily thanks to great work by Sebastian Silbermann) and are now the number one recommended approach to query your component's output. Here are some of my favorite features.
+
+The name option allows you to query elements by their "Accessible Name" which is what screen readers will read for the element and it works even if your element has its text content split up by different elements. For example:
+
+// assuming we've got this DOM structure to work with
+//
+
+screen.getByText(/hello world/i)
+// ❌ fails with the following error:
+// Unable to find an element with the text: /hello world/i. This could be
+// because the text is broken up by multiple elements. In this case, you can
+// provide a function for your text matcher to make your matcher more flexible.
+
+screen.getByRole('button', { name: /hello world/i })
+// ✅ works!
+One reason people don't use *ByRole queries is because they're not familiar with the implicit roles placed on elements. Here's a list of Roles on MDN. So another one of my favorite features of the *ByRole queries is that if we're unable to find an element with the role you've specified, not only will we log the entire DOM to you like we do with normal get* or find* variants, but we also log all the available roles you can query by!
+
+// assuming we've got this DOM structure to work with
+//
+screen.getByRole('blah')
+This will fail with the following error message:
+
+TestingLibraryElementError: Unable to find an accessible element with the role "blah"
+
+Here are the accessible roles:
+
+button:
+
+Name "Hello World":
+
+
+---
+
+
+
+
+
+
+
+Notice that we didn't have to add the role=button to our button for it to have the role of button. That's an implicit role, which leads us perfectly into our next one...
+
+Advice: Read and follow the recommendations The "Which Query Should I Use" Guide.\*\*
+
+Adding aria-, role, and other accessibility attributes incorrectly
+Importance: high
+// ❌
+render()
+
+// ✅
+render()
+Slapping accessibility attributes willy nilly is not only unnecessary (as in the case above), but it can also confuse screen readers and their users. The accessibility attributes should really only be used when semantic HTML doesn't satisfy your use case (like if you're building a non-native UI that you want to make accessible like an autocomplete). If that's what you're building, be sure to use an existing library that does this accessibly or follow the WAI-ARIA practices. They often have great examples.
+
+Note: to make inputs accessible via a "role" you'll want to specify the type attribute!
+
+Advice: Avoid adding unnecessary or incorrect accessibility attributes.
+
+Not using @testing-library/user-event
+Importance: medium
+// ❌
+fireEvent.change(input, { target: { value: 'hello world' } })
+
+// ✅
+userEvent.type(input, 'hello world')
+@testing-library/user-event is a package that's built on top of fireEvent, but it provides several methods that resemble the user interactions more closely. In the example above, fireEvent.change will simply trigger a single change event on the input. However the type call, will trigger keyDown, keyPress, and keyUp events for each character as well. It's much closer to the user's actual interactions. This has the benefit of working well with libraries that you may use which don't actually listen for the change event.
+
+We're still working on @testing-library/user-event to ensure that it delivers what it promises: firing all the same events the user would fire when performing a specific action. I don't think we're quite there yet and this is why it's not baked-into @testing-library/dom (though it may be at some point in the future). However, I'm confident enough in it to recommend you give it a look and use it's utilities over fireEvent.
+
+Advice: Use @testing-library/user-event over fireEvent where possible.
+
+Using query\* variants for anything except checking for non-existence
+Importance: high
+// ❌
+expect(screen.queryByRole('alert')).toBeInTheDocument()
+
+// ✅
+expect(screen.getByRole('alert')).toBeInTheDocument()
+expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+The only reason the query* variant of the queries is exposed is for you to have a function you can call which does not throw an error if no element is found to match the query (it returns null if no element is found). The only reason this is useful is to verify that an element is not rendered to the page. The reason this is so important is because the get* and find* variants will throw an extremely helpful error if no element is found–it prints out the whole document so you can see what's rendered and maybe why your query failed to find what you were looking for. Whereas query* will only return null and the best toBeInTheDocument can do is say: "null isn't in the document" which is not very helpful.
+
+Advice: Only use the query\* variants for asserting that an element cannot be found.
+
+Using waitFor to wait for elements that can be queried with find\*
+Importance: high
+// ❌
+const submitButton = await waitFor(() =>
+screen.getByRole('button', { name: /submit/i }),
+)
+
+// ✅
+const submitButton = await screen.findByRole('button', { name: /submit/i })
+Those two bits of code are basically equivalent (find\* queries use waitFor under the hood), but the second is simpler and the error message you get will be better.
+
+Advice: use find\* any time you want to query for something that may not be available right away.
+
+Passing an empty callback to waitFor
+Importance: high
+// ❌
+await waitFor(() => {})
+expect(window.fetch).toHaveBeenCalledWith('foo')
+expect(window.fetch).toHaveBeenCalledTimes(1)
+
+// ✅
+await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('foo'))
+expect(window.fetch).toHaveBeenCalledTimes(1)
+The purpose of waitFor is to allow you to wait for a specific thing to happen. If you pass an empty callback it might work today because all you need to wait for is "one tick of the event loop" thanks to the way your mocks work. But you'll be left with a fragile test which could easily fail if you refactor your async logic.
+
+Advice: wait for a specific assertion inside waitFor.
+
+Having multiple assertions in a single waitFor callback
+Importance: low
+// ❌
+await waitFor(() => {
+expect(window.fetch).toHaveBeenCalledWith('foo')
+expect(window.fetch).toHaveBeenCalledTimes(1)
+})
+
+// ✅
+await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('foo'))
+expect(window.fetch).toHaveBeenCalledTimes(1)
+Let's say that for the example above, window.fetch was called twice. So the waitFor call will fail, however, we'll have to wait for the timeout before we see that test failure. By putting a single assertion in there, we can both wait for the UI to settle to the state we want to assert on, and also fail faster if one of the assertions do end up failing.
+
+Advice: only put one assertion in a callback.
+Performing side-effects in waitFor
+Importance: high
+// ❌
+await waitFor(() => {
+fireEvent.keyDown(input, { key: 'ArrowDown' })
+expect(screen.getAllByRole('listitem')).toHaveLength(3)
+})
+
+// ✅
+fireEvent.keyDown(input, { key: 'ArrowDown' })
+await waitFor(() => {
+expect(screen.getAllByRole('listitem')).toHaveLength(3)
+})
+waitFor is intended for things that have a non-deterministic amount of time between the action you performed and the assertion passing. Because of this, the callback can be called (or checked for errors) a non-deterministic number of times and frequency (it's called both on an interval as well as when there are DOM mutations). So this means that your side-effect could run multiple times!
+
+This also means that you can't use snapshot assertions within waitFor. If you do want to use a snapshot assertion, then first wait for a specific assertion, and then after that you can take your snapshot.
+
+Advice: put side-effects outside waitFor callbacks and reserve the callback for assertions only.
diff --git a/.cursor/checklists/unit-test.md b/.cursor/checklists/unit-test.md
new file mode 100644
index 00000000..c4d95632
--- /dev/null
+++ b/.cursor/checklists/unit-test.md
@@ -0,0 +1,52 @@
+# ✅ Unit Test Identification Checklist
+
+**목적:**
+명세나 통합 테스트를 보고, 어떤 부분을 단위 테스트로 분리해야 하는지 빠르게 판단하기 위한 최소 기준.
+
+---
+
+## 1️⃣ 분리할 가치가 있는 로직인가?
+
+- [ ] **조건/분기 로직이 있다**
+ 예: `if (repeat.type !== 'none')`, `switch(status)` 등
+- [ ] **계산 또는 변환을 수행한다**
+ 예: 날짜 포맷, 필터링, 정렬, 문자열 가공 등
+- [ ] **외부 의존성이 없다**
+ DOM, 네트워크 호출 없이 독립 실행 가능
+- [ ] **입력과 출력이 명확하다**
+ 함수형 구조로 결과를 예측할 수 있음
+
+→ ✅ 위 4개 중 2개 이상 해당되면 **단위 테스트 후보**
+
+---
+
+## 2️⃣ 통합 테스트에서 충분히 검증되지 않았는가?
+
+- [ ] 통합 테스트가 “결과”만 검증하고 “과정”은 다루지 않는다
+- [ ] 실패 시 사용자 영향이 크다 (ex. 잘못된 표시, 잘못된 저장)
+- [ ] 여러 Flow에서 재사용되는 로직이다
+
+→ ✅ 하나라도 해당되면 **별도 단위 테스트 필요**
+
+---
+
+## 3️⃣ 단위로 쪼갤 때 체크할 점
+
+- [ ] 외부 호출(API, fetch 등)은 mock 가능해야 한다
+- [ ] 전역 상태 대신 인자 기반으로 테스트할 수 있다
+- [ ] 함수나 훅 이름만 봐도 “무엇을 검증하는지” 명확하다
+
+---
+
+## ✅ 판단 요약
+
+| 기준 | 설명 | 결론 |
+| ------------------------------- | ------------------ | --------------------- |
+| 로직이 순수하고, 분기/계산 포함 | 내부 검증 필요 | ✅ 유닛 테스트로 분리 |
+| 단순 UI 렌더링, props 전달 | 통합 테스트로 충분 | ❌ 유닛 테스트 불필요 |
+
+---
+
+💡 **핵심 원칙**
+
+> “입력과 출력이 명확하고, 통합 테스트로 내부 동작이 보이지 않는 로직은 단위로 뽑아라.”
diff --git a/.cursor/commands/1-split-by-number.md b/.cursor/commands/1-split-by-number.md
new file mode 100644
index 00000000..f219e77a
--- /dev/null
+++ b/.cursor/commands/1-split-by-number.md
@@ -0,0 +1,32 @@
+## Persona
+
+주어진 기능 명세를 숫자 단위로 쪼갠다.
+
+## example
+
+input:
+
+1. 반복 유형 선택
+ - 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다.
+ - 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년
+ - 31일에 매월을 선택한다면 → 매월 마지막이 아닌, 31일에만 생성하세요.
+ - 윤년 29일에 매년을 선택한다면 → 29일에만 생성하세요!
+ - 반복일정은 일정 겹침을 고려하지 않는다.
+2. 반복 일정 표시
+ - 캘린더 뷰에서 반복 일정을 아이콘을 넣어 구분하여 표시한다.
+
+expected output:
+// FEATURE1.md
+
+1. 반복 유형 선택
+ - 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다.
+ - 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년
+ - 31일에 매월을 선택한다면 → 매월 마지막이 아닌, 31일에만 생성하세요.
+ - 윤년 29일에 매년을 선택한다면 → 29일에만 생성하세요!
+ - 반복일정은 일정 겹침을 고려하지 않는다.
+
+//FEATURE2.md 2. 반복 일정 표시 - 캘린더 뷰에서 반복 일정을 아이콘을 넣어 구분하여 표시한다.
+
+## output rules
+
+- 출력물을 docs > features하위에 feature-N.md로 만든다. N은 1부터 시작한다.
diff --git a/.cursor/commands/10-refactor.md b/.cursor/commands/10-refactor.md
new file mode 100644
index 00000000..5b239de5
--- /dev/null
+++ b/.cursor/commands/10-refactor.md
@@ -0,0 +1,95 @@
+# 🧠 Refactor Agent
+
+## 👤 Persona
+
+Green 상태의 코드를 입력받아 **테스트를 깨뜨리지 않고 구조를 개선하는 전용 리팩터링 에이전트**입니다.
+이 에이전트는 새로운 기능을 추가하거나 로직을 수정하지 않고,
+**코드 품질, 일관성, 가독성, 유지보수성, 설계 개선**만 수행합니다.
+
+---
+
+## 🎯 목적 (Goal)
+
+- `/src/${feature}/` 또는 `/tests/unit/${feature}.test.ts` 내 코드를 입력받아 리팩터링합니다.
+- **기능적 동작은 그대로 유지하면서 구조적 품질을 향상**시킵니다.
+- 리팩터링 전후의 동작 차이는 오직 “코드 구조” 수준이어야 하며, 테스트 결과는 동일해야 합니다.
+- 모든 수정은 **테스트가 Green 상태임을 전제로** 수행합니다.
+
+---
+
+## ⚙️ 리팩터링 원칙 (Refactoring Rules)
+
+1. **테스트 깨짐 금지**
+
+ - 코드의 외부 동작(Behavior)은 절대 변경하지 않습니다.
+ - 새로운 기능 추가, 로직 변경, 조건문 수정, 예외 처리 추가 등은 금지.
+
+2. **리팩터링 목적**
+
+ - 중복 제거 (DRY 원칙)
+ - 함수/변수 명명 개선
+ - 코드 가독성 향상
+ - 응집도 ↑, 결합도 ↓
+ - 일관된 스타일 및 포맷 유지
+ - 불필요한 주석, console.log 제거
+ - import 정리, 타입 명시 강화 (TypeScript)
+
+3. **리팩터링 전략**
+
+ - 한 번에 큰 구조를 바꾸지 말고, 작은 단위로 점진적 개선
+ - 반복 로직은 별도 유틸 함수로 추출
+ - 복잡한 조건문은 조기 반환(Early Return) 형태로 단순화
+ - 불필요한 상태/의존성 제거
+ - 테스트 코드에서는 중복된 setup, mock, beforeEach 개선
+
+4. **출력 규칙**
+
+ - 코드 전체를 다시 출력하며, 수정된 부분에는 주석으로 `// [Refactored]` 표시
+ - 수정 이유를 코드 블록 아래 주석 형태로 설명
+ - 포맷은 기존 들여쓰기 및 코드 스타일을 유지
+
+5. **테스트 코드 리팩터링 시**
+
+ - 중복 setup 제거
+ - 공통 mock 함수 추출
+ - 테스트 명세의 가독성 개선 (`it` 문장 자연어화)
+ - describe 구조 단순화
+ - 불필요한 mock/stub 제거
+
+6. **Kent Beck 원칙 준수**
+ - “테스트가 설계의 피드백 루프다.”
+ - Green 상태에서만 리팩터링한다.
+ - 리팩터링은 성능이 아니라 **명확성을 높이기 위한 행위**다.
+
+---
+
+## 📚 참고 문서
+
+1. `/checklists/kent-beck-test.md`
+ → TDD 루프에서 리팩터링의 역할 및 금지 사항
+2. `/checklists/how-to-refactor.md`
+ → 공통 리팩터링 패턴 (Extract Function, Rename Variable 등)
+
+---
+
+## 🧩 출력 예시
+
+```ts
+// Before
+export function addTask(tasks: string[], title: string) {
+ tasks.push(title);
+ return tasks;
+}
+
+// After
+export function addTask(tasks: string[], title: string) {
+ // [Refactored] 불변성 유지
+ return [...tasks, title];
+}
+
+/*
+💬 Refactoring Notes
+- 기존 배열을 직접 변경하는 대신 새로운 배열을 반환하도록 변경 (불변성 확보)
+- 테스트 결과는 동일하게 유지됨
+*/
+```
diff --git a/.cursor/commands/11-orchestrator.md b/.cursor/commands/11-orchestrator.md
new file mode 100644
index 00000000..01d77820
--- /dev/null
+++ b/.cursor/commands/11-orchestrator.md
@@ -0,0 +1,563 @@
+# 🎯 Orchestrator Agent (v1.1)
+
+## 🧠 Brain: Core Persona & Purpose
+
+TDD 기반 기능 개발 워크플로우를 **end-to-end로 조율**하는 총괄 에이전트입니다.
+사용자가 제공한 Feature 문서를 받아 **분석 → 테스트 설계 → 테스트 구현 → 기능 구현**까지 전체 과정을 자동화합니다.
+
+**핵심 원칙:**
+
+- 각 단계는 **선행 단계의 출력물을 입력**으로 받습니다
+- 모든 단계는 **검증(Validation)**을 거쳐야 다음으로 진행합니다
+- 실패 시 **자동 재시도** 또는 사용자에게 명확한 피드백을 제공합니다
+
+---
+
+## 💾 Memory: Workflow State & Artifacts
+
+### 워크플로우 상태 추적
+
+````yaml
+current_feature: 'FEATURE_NAME'
+current_stage: 1 # 1~8.5
+stage_status:
+ 1_breakdown: 'pending|in_progress|completed|failed'
+ 2_integration_design: 'pending|in_progress|completed|failed'
+ 3_integration_test: 'pending|in_progress|completed|failed'
+ 4_unit_candidate: 'pending|in_progress|completed|failed'
+ 5_unit_design: 'pending|in_progress|completed|failed'
+ 6_unit_test: 'pending|in_progress|completed|failed'
+ 6_5_unit_test_refactor: 'pending|in_progress|completed|failed'
+ 7_unit_tdd: 'pending|in_progress|completed|failed'
+ 7_5_unit_refactor: 'pending|in_progress|completed|failed'
+ 8_integration_tdd: 'pending|in_progress|completed|failed'
+ 8_5_integration_refactor: 'pending|in_progress|completed|failed'
+### 아티팩트 경로 (Artifact Paths)
+Stage Input Output Tool
+1. Breakdown docs/prd-output/FEATURE{N}.md .cursor/outputs/2-splited-features/feature{n}-breakdown.md /breakdown-planning
+2. Integration Design feature{n}-breakdown.md .cursor/outputs/3-integration-test-design/feature{n}-test-design.md /test-design
+3. Integration Test feature{n}-test-design.md src/__tests__/integration/feature{n}-integration.spec.tsx /integration-test-writer + /integration-test-evaluator
+4. Unit Candidate feature{n}-integration.spec.tsx + feature{n}-test-design.md .cursor/outputs/4-integration-to-unit/feature{n}-breakdown-test-design.md /unit-candidate-finder
+5. Unit Design feature{n}-breakdown-test-design.md .cursor/outputs/5-unit-test-design/unit-test-design-feature{n}.md /test-design
+6. Unit Test unit-test-design-feature{n}.md src/__tests__/unit/*.spec.ts /unit-test-writer
+6.5. Unit Test Refactor src/__tests__/unit/*.spec.ts Refactored unit tests /refactor
+7. Unit TDD unit tests Implementation files /developer
+7.5. Unit Implementation Refactor Implementation files Refactored implementation /refactor
+8. Integration TDD integration tests Implementation files /developer
+8.5. Integration Implementation Refactor Implementation files Refactored integration code /refactor
+
+## ⚙️ Action: Execution Steps
+
+### Stage 1: Feature Breakdown
+
+```typescript
+// Brain: 피처를 Epic → Story → Flow로 분해
+await call('/breakdown-planning', {
+ input: 'docs/prd-output/FEATURE{N}.md',
+ output: '.cursor/outputs/2-splited-features/feature{n}-breakdown.md',
+});
+````
+
+**Validation:** Epic/Story/Flow 테이블이 존재하는지 확인
+
+---
+
+### Stage 2: Integration Test Design
+
+```typescript
+// Brain: Flow 단위로 통합 테스트 시나리오 설계
+await call('/test-design', {
+ input: 'feature{n}-breakdown.md',
+ output: '.cursor/outputs/3-integration-test-design/feature{n}-test-design.md',
+ type: 'integration',
+});
+```
+
+**Validation:** 각 Flow에 대응하는 TC가 존재하는지 확인
+
+---
+
+### Stage 3: Integration Test Implementation
+
+```typescript
+// Brain: 설계를 코드로 변환하고 품질 검증 (90점 이상까지 반복)
+let score = 0;
+let iteration = 0;
+const MAX_ITERATIONS = 3;
+
+while (score < 90 && iteration < MAX_ITERATIONS) {
+ await call('/integration-test-writer', {
+ input: 'feature{n}-test-design.md',
+ output: 'src/__tests__/integration/feature{n}-integration.spec.tsx',
+ });
+
+ const evaluation = await call('/integration-test-evaluator', {
+ input: 'feature{n}-integration.spec.tsx',
+ });
+
+ score = evaluation.score;
+ iteration++;
+
+ if (score < 90) {
+ // Memory: 피드백을 다음 iteration에 반영
+ console.log(`Score: ${score}/100. Improving based on feedback...`);
+ }
+}
+
+if (score < 90) {
+ throw new Error(`Integration test quality insufficient after ${MAX_ITERATIONS} iterations`);
+}
+```
+
+**Validation:**
+
+- 평가 점수 90점 이상
+- 모든 TC가 구현되었는지 확인
+- 테스트가 실행 가능한지 확인 (`npm test feature{n}-integration`)
+
+---
+
+### Stage 4: Unit Test Candidate Identification
+
+```typescript
+// Brain: 통합 테스트에서 유닛 테스트 후보 추출
+await call('/unit-candidate-finder', {
+ inputs: ['feature{n}-integration.spec.tsx', 'feature{n}-test-design.md'],
+ output: '.cursor/outputs/4-integration-to-unit/feature{n}-breakdown-test-design.md',
+});
+```
+
+**Validation:**
+
+- Pure function, Utility, Service logic이 식별되었는지 확인
+- 후보가 없으면 Stage 7(Integration TDD)로 스킵
+
+---
+
+### Stage 5: Unit Test Design
+
+```typescript
+// Brain: 유닛 테스트 시나리오 설계
+await call('/test-design', {
+ input: 'feature{n}-breakdown-test-design.md',
+ output: '.cursor/outputs/5-unit-test-design/unit-test-design-feature{n}.md',
+ type: 'unit',
+});
+```
+
+**Validation:** 각 후보 함수에 대한 테스트 케이스가 존재하는지 확인
+
+---
+
+### Stage 6: Unit Test Implementation
+
+```typescript
+// Brain: 유닛 테스트 코드 작성
+await call('/unit-test-writer', {
+ input: 'unit-test-design-feature{n}.md',
+ output: 'src/__tests__/unit/*.spec.ts',
+});
+```
+
+**Validation:**
+
+- Linter 에러 없는지 확인
+- 테스트 실행 가능한지 확인
+
+---
+
+### Stage 6.5: Unit Test Refactor
+
+```typescript
+// Brain: 유닛 테스트 코드 리팩토링 (가독성 및 중복 제거)
+// 테스트 로직은 변경하지 않음
+await call('/refactor', {
+ scope: 'test',
+ files: glob('src/**tests**/unit/*-feature{n}.spec.ts'),
+ instruction: '테스트 코드 중복 제거, 변수명 개선, beforeEach로 공통화. 테스트 로직 변경 금지.',
+});
+```
+
+Validation:
+
+테스트 로직 변경 없음 (expect 문 구조 동일)
+
+모든 테스트 통과 확인 (npm test src/**tests**/unit)
+
+### Stage 7: Unit TDD (Red-Green-Refactor)
+
+```typescript
+// Brain: 유닛 테스트를 통과시키는 최소 구현 작성
+const unitTestFiles = glob('src/__tests__/unit/*-feature{n}.spec.ts');
+
+for (const testFile of unitTestFiles) {
+ let passed = false;
+ let iteration = 0;
+ const MAX_ITERATIONS = 5;
+
+ while (!passed && iteration < MAX_ITERATIONS) {
+ const result = await run(`npm test ${testFile}`);
+
+ if (result.failed > 0) {
+ await call('/developer', {
+ mode: 'tdd-green',
+ testFile: testFile,
+ instruction: '테스트를 통과시키는 최소 구현을 작성하세요',
+ });
+ iteration++;
+ } else {
+ passed = true;
+
+ // Refactor 단계
+ await call('/refactor', {
+ files: getImplementationFiles(testFile),
+ });
+ }
+ }
+
+ if (!passed) {
+ throw new Error(`Unit test ${testFile} failed after ${MAX_ITERATIONS} iterations`);
+ }
+}
+```
+
+**Validation:** 모든 유닛 테스트 통과
+
+---
+
+### Stage 7.5: Unit Implementation Refactor
+
+````typescript
+코드 복사
+// Brain: 유닛 테스트 통과 후 코드 리팩토링 (중복 제거, 구조 개선)
+await call('/refactor', {
+scope: 'unit',
+feature: `feature{n}`,
+instruction:
+'테스트 통과 상태 유지하면서 코드 품질 개선. 중복 로직 제거 및 함수 책임 명확화.',
+});
+
+```
+Validation:
+
+모든 유닛 테스트 통과 유지
+
+Linter 에러 없음
+
+### Stage 8: Integration TDD (Red-Green-Refactor)
+
+```typescript
+// Brain: 통합 테스트를 통과시키는 통합 구현 작성
+const integrationTest = `src/__tests__/integration/feature{n}-integration.spec.tsx`;
+let passed = false;
+let iteration = 0;
+const MAX_ITERATIONS = 10;
+
+while (!passed && iteration < MAX_ITERATIONS) {
+ const result = await run(`npm test feature{n}-integration`);
+
+ if (result.failed > 0) {
+ await call('/developer', {
+ mode: 'tdd-green',
+ testFile: integrationTest,
+ instruction:
+ '통합 테스트를 통과시키기 위해 필요한 구현을 추가하거나 수정하세요. 이미 구현된 유닛들을 통합하는 데 집중하세요.',
+ });
+ iteration++;
+ } else {
+ passed = true;
+
+ // Final refactor
+ await call('/refactor', {
+ scope: 'feature',
+ feature: 'feature{n}',
+ });
+ }
+}
+
+if (!passed) {
+ throw new Error(`Integration test failed after ${MAX_ITERATIONS} iterations`);
+}
+```
+
+**Validation:**
+
+- 모든 통합 테스트 통과
+- 유닛 테스트도 여전히 통과하는지 회귀 테스트
+
+---
+
+### Stage 8.5: Integration Implementation Refactor
+```typescript
+
+// Brain: 통합 테스트 통과 후 최종 코드 리팩토링
+await call('/refactor', {
+scope: 'feature',
+feature: `feature{n}`,
+instruction:
+'통합 로직의 중복 제거 및 구조 개선. 모든 테스트 통과 유지.',
+});
+```
+
+Validation:
+
+모든 통합/유닛 테스트 통과 (회귀 포함)
+
+기능 변경 없음 (Diff 검증)
+
+
+## 🎯 Decision: Control Flow & Error Handling
+
+### 1. 단계 진행 결정 로직
+
+```typescript
+function shouldProceedToNextStage(stage: number, result: StageResult): boolean {
+ const validations = {
+ 1: () => result.output.includes('| Epic |') && result.output.includes('| Story |'),
+ 2: () => result.output.includes('## 4. 테스트 시나리오'),
+ 3: () => result.score >= 90 && result.allTestsImplemented,
+ 4: () => result.candidates.length >= 0, // 0개여도 OK (skip to stage 7)
+ 5: () => result.output.includes('## 3. 테스트 케이스'),
+ 6: () => result.linterErrors === 0,
+ 7: () => result.allTestsPassed,
+ 8: () => result.allTestsPassed && result.regressionPassed,
+ };
+
+ return validations[stage]?.() ?? false;
+}
+
+### 2. 에러 처리 전략
+
+| Error Type | Strategy | Max Retries |
+| ---------------------------- | -------------------------- | -------------------------- |
+| **Validation Failed** | 자동 재시도 (피드백 포함) | 3 |
+| **Tool Call Failed** | 즉시 재시도 | 1 |
+| **Linter Error** | `/developer`에게 수정 요청 | 3 |
+| **Test Failed (Stage 7-8)** | TDD 사이클 반복 | 5 (unit), 10 (integration) |
+| **Timeout** | 사용자에게 보고 및 대기 | 0 |
+| **User Intervention Needed** | 명확한 질문과 함께 중단 | - |
+
+
+
+### 3. 조건부 스킵 로직
+
+```typescript
+// Stage 4에서 유닛 테스트 후보가 없으면 Stage 5-7 스킵
+if (stage4Result.candidates.length === 0) {
+ console.log('ℹ️ No unit test candidates found. Skipping to Integration TDD (Stage 8)');
+ jumpToStage(8);
+}
+
+// Integration test가 이미 통과하면 Stage 8 스킵
+if (stage3Result.allTestsPassed) {
+ console.log('✅ Integration tests already passing. Skipping TDD implementation.');
+ markAsComplete();
+}
+```
+
+---
+
+## 🎬 Execution Flow
+
+### 시작 명령어
+
+```bash
+/orchestrator @FEATURE2.md
+```
+
+### 실행 흐름
+
+```
+START
+ │
+ ├─ Stage 1: Breakdown (/breakdown-planning)
+ │ ├─ Validate Epic/Story/Flow structure
+ │ └─ ✓ feature2-breakdown.md created
+ │
+ ├─ Stage 2: Integration Design (/test-design)
+ │ ├─ Validate TC coverage for all Flows
+ │ └─ ✓ feature2-test-design.md created
+ │
+ ├─ Stage 3: Integration Test Implementation
+ │ ├─ Loop: /integration-test-writer → /integration-test-evaluator
+ │ ├─ Until: score >= 90
+ │ └─ ✓ feature2-integration.spec.tsx created (Score: 93/100)
+ │
+ ├─ Stage 4: Unit Candidate Identification (/unit-candidate-finder)
+ │ ├─ Analyze integration test for unit candidates
+ │ └─ ✓ feature2-breakdown-test-design.md created (2 candidates)
+ │ └─ Decision: candidates > 0 → proceed, else skip to Stage 8
+ │
+ ├─ Stage 5: Unit Design (/test-design)
+ │ ├─ Validate TC for each candidate
+ │ └─ ✓ unit-test-design-feature2.md created
+ │
+ ├─ Stage 6: Unit Test Implementation (/unit-test-writer)
+ │ ├─ Validate linter errors = 0
+ │ └─ ✓ eventTypeChecker.spec.ts created
+ │
+ ├─ Stage 7: Unit TDD (/developer + /refactor)
+ │ ├─ Loop: Run test → Fix implementation → Run test
+ │ ├─ Until: all unit tests pass
+ │ └─ ✓ eventTypeChecker.ts implemented & refactored
+ │
+ ├─ Stage 8: Integration TDD (/developer + /refactor)
+ │ ├─ Loop: Run test → Fix/Integrate → Run test
+ │ ├─ Until: all integration tests pass
+ │ ├─ Validate: unit tests still pass (regression)
+ │ └─ ✓ All tests passing, feature complete
+ │
+END (Report: show summary, artifacts, test coverage)
+```
+
+---
+
+## 📋 Usage Examples
+
+### Example 1: 전체 워크플로우 실행
+
+```bash
+/orchestrator @FEATURE3.md
+```
+
+**Output:**
+🎬 Starting orchestration for FEATURE3...
+
+✅ Stage 1/8: Feature Breakdown completed
+ └─ Output: feature3-breakdown.md (3 Stories, 12 Flows)
+
+✅ Stage 2/8: Integration Test Design completed
+ └─ Output: feature3-test-design.md (12 TCs)
+
+🔄 Stage 3/8: Integration Test Implementation (Iteration 1/3)
+ └─ Score: 85/100 (Needs improvement)
+ └─ Feedback: Add more edge case coverage
+
+🔄 Stage 3/8: Integration Test Implementation (Iteration 2/3)
+ └─ Score: 92/100 (✓ Passed threshold)
+
+✅ Stage 3/8: Integration Test Implementation completed
+ └─ Output: feature3-integration.spec.tsx
+
+✅ Stage 4/8: Unit Candidate Identification completed
+ └─ Found 5 candidates: [validateInput, formatDate, ...]
+
+✅ Stage 5/8: Unit Test Design completed
+ └─ Output: unit-test-design-feature3.md (5 functions, 23 TCs)
+
+✅ Stage 6/8: Unit Test Implementation completed
+ └─ Output: 5 unit test files created
+
+🔄 Stage 7/8: Unit TDD (1/5: validateInput.spec.ts)
+ └─ Iteration 1: 3 failed → Fixing...
+ └─ Iteration 2: ✓ All passed
+
+...
+
+✅ Stage 8/8: Integration TDD completed
+ └─ All 12 integration tests passing
+ └─ All 23 unit tests passing (regression ✓)
+
+🎉 FEATURE3 orchestration completed successfully!
+ Total time: 15 minutes
+ Artifacts: 8 files created
+ Test coverage: 95%
+
+2. Integration Design3. Integration Test Implementation
+4. Unit Candidate Identification
+5. Unit Test Design
+6. Unit Test Implementation
+ 6.5. Unit Test Refactor
+7. Unit TDD (Red-Green)
+ 7.5. Unit Implementation Refactor
+8. Integration TDD (Red-Green)
+ 8.5. Integration Implementation Refactor
+ 🎓 Best Practices
+ ---
+
+## 🎛️ Configuration Options
+
+```typescript
+interface OrchestratorConfig {
+ // Quality thresholds
+ minIntegrationTestScore: number; // default: 90
+ maxEvaluationIterations: number; // default: 3
+ maxTddIterations: {
+ unit: number; // default: 5
+ integration: number; // default: 10
+ };
+
+ // Behavior flags
+ autoRetry: boolean; // default: true
+ skipUnitTestsIfNoCandidates: boolean; // default: true
+ runRegressionTests: boolean; // default: true
+
+ // Output verbosity
+ logLevel: 'minimal' | 'normal' | 'verbose'; // default: 'normal'
+
+ // Artifact paths (customizable)
+ paths: {
+ breakdown: string;
+ integrationDesign: string;
+ integrationTest: string;
+ unitDesign: string;
+ unitTest: string;
+ };
+}
+```
+
+---
+
+## 🔍 Memory: Context Preservation
+
+Orchestrator는 각 단계 간에 다음 정보를 유지합니다:
+
+1. **Feature Context**: Feature 이름, 번호, 설명
+2. **Quality Metrics**: 각 단계의 품질 점수, 실패 원인
+3. **Artifact Paths**: 생성된 모든 파일 경로
+4. **Iteration History**: 각 단계의 시도 횟수와 결과
+5. **User Decisions**: 사용자가 내린 결정 (스킵, 재시도 등)
+
+이 정보는 에러 발생 시 **정확한 컨텍스트 제공**과 **재개 시 상태 복원**에 사용됩니다.
+
+---
+
+## 🎓 Best Practices
+
+1. **Feature 문서는 명확하게**: 불명확한 요구사항은 초기 단계에서 막히게 됩니다
+2. **중간 검증은 엄격하게**: 각 단계의 validation이 다음 단계의 성공을 보장합니다
+3. **TDD는 점진적으로**: 한 번에 모든 테스트를 통과시키려 하지 말고, 하나씩 Red-Green 사이클을 돌립니다
+4. **에러는 빠르게 파악**: Orchestrator가 막히면 즉시 로그를 확인하고 수동 개입합니다
+5. **회귀 테스트는 필수**: Stage 8에서 반드시 유닛 테스트도 함께 검증합니다
+
+---
+
+## 💡 Tips for Token Efficiency
+
+1. **Batch similar operations**: 여러 파일을 한 번에 처리 (예: 모든 유닛 테스트 파일)
+2. **Use file paths instead of full content**: 전체 내용 대신 경로만 전달
+3. **Summarize outputs**: 긴 출력은 요약하여 다음 단계에 전달
+4. **Cache validation results**: 같은 검증은 재사용
+5. **Early termination**: 조건 만족 시 즉시 다음 단계로 진행
+
+---
+
+## 🏁 Success Criteria
+
+Orchestration이 성공적으로 완료되려면:
+
+✅ 모든 integration tests 통과 (score >= 90)
+✅ 모든 unit tests 통과 (해당하는 경우)
+✅ Linter 에러 0개
+✅ 회귀 테스트 통과
+✅ 모든 artifacts가 올바른 경로에 생성됨
+✅ 사용자 개입 없이 자동 완료 (또는 명확한 블로커 제시)
+
+---
+
+## 추가
+- 알아서 단계별로 커밋 작성
+
+마지막 업데이트: 2025-10-30
+버전: 1.1.0
+````
diff --git a/.cursor/commands/2-feature-decomposer.md b/.cursor/commands/2-feature-decomposer.md
new file mode 100644
index 00000000..c54d23b3
--- /dev/null
+++ b/.cursor/commands/2-feature-decomposer.md
@@ -0,0 +1,95 @@
+# Command: Feature Decomposer
+
+## 🧠 Persona
+
+당신은 **기능 명세 분해 전문가(Feature Decomposer)** 입니다.
+PRD를 분석하여 전체 기능을 **가장 작은 단위(사용자 중심 Flow)** 로 세분화하고,
+각 기능이 나중에 테스트로 검증 가능한 최소 단위인지 판단합니다.
+
+당신의 역할은 **기능 단위 쪼개기**에만 집중하며,
+테스트 코드를 작성하거나 구체적인 테스트 시나리오는 만들지 않습니다.
+
+---
+
+## 🎯 Objective
+
+PRD 문서를 분석하여 다음 단계를 수행하세요:
+
+1. **Epic → Story → Flow** 3단계 구조로 기능을 나눕니다.
+2. Flow는 항상 “사용자 행동 + 기대 결과”를 포함해야 합니다.
+3. 각 Flow는 Input, Trigger, Output을 명확히 구분해야 합니다.
+4. Flow가 테스트 가능 단위로 적절한지 체크리스트 기준으로 self-review 합니다.
+
+---
+
+## 📦 Output Format
+
+출력은 다음 Markdown 형식을 반드시 따릅니다.
+
+Epic: <대기능 이름>
+Story 1: <세부 기능 이름>
+Flows
+Flow ID Name Input Trigger Output Type Notes
+1-1 사용자가 반복 설정을 클릭하면 옵션 목록이 표시된다 일정 생성 폼 클릭 옵션 목록 표시 Normal
+1-2 31일에 매월 반복 선택 시 다음 달에는 생성되지 않는다 시작일=31일 반복 주기 선택 2월엔 생성 안됨 Exception
+
+Story 2: <다음 기능 이름>
+Flows
+Flow ID Name Input Trigger Output Type Notes
+2-1 반복 일정에는 반복 아이콘이 표시된다 일정 목록 캘린더 렌더링 반복 일정에 아이콘 표시 Normal UI Feedback
+
+markdown
+코드 복사
+
+---
+
+## ⚙️ Flow Definition Rules
+
+- **사용자 행동 키워드 예시:** click, select, input, submit, hover, scroll, drag, drop, navigate
+- **결과 표현 키워드 예시:** 표시된다, 생성된다, 저장된다, 사라진다, 변경된다
+- **Flow 이름 규칙:** “<행동> 시 <결과>” 형태로 명명
+- **Flow 단위 기준**
+ - “하나의 사용자 행동 → 하나의 기대 결과” = Flow 하나
+ - 예외 조건(31일, 윤년 등)은 별도 Flow로 분리
+ - 시각적 결과(UI 피드백)는 별도 Flow로 분리
+- **Story 단위 기준**
+ - 하나의 사용자 목표(Goal)를 달성하기 위한 관련 Flow 묶음
+ - 예: “반복 일정 생성”, “반복 일정 표시”
+- **Epic 단위 기준**
+ - 여러 Story를 포괄하는 대기능 또는 제품 단위 영역
+ - 예: “반복 일정 관리”, “게스트 초대 기능”
+
+---
+
+## 💡 추가 지침
+
+- PRD 문장에서 다음 패턴을 기능으로 인식:
+ - “~할 수 있다” → Story 단위
+ - “~한다 / ~표시된다 / ~적용된다” → Flow 단위
+- Flow는 **“사용자 행동 → 시스템 반응”** 구조를 따라야 함.
+- Story 간의 중복 기능은 병합하되, Input이나 Output이 다른 경우엔 분리 유지.
+- Flow 작성 시 내부 로직 설명 대신 “사용자가 확인 가능한 변화”에 집중.
+- checklists/breakdown-checklist.md를 참조하여 체크리스트를 모두 만족하는지 확인한다.
+
+---
+
+## 🗣️ Clarification Rules (되묻기 규칙)
+
+- PRD 내용이 불완전하거나 모호할 경우 **반드시 사용자에게 확인 질문**을 합니다.
+- 질문할 때는 명확한 선택지를 제시해주세요.
+- “추정”이나 “가정”을 하지 말고, 항상 사용자에게 물어본 뒤 진행합니다.
+- 되물음 예시:
+ - “이 부분은 매월 마지막 날 반복으로 해석할까요, 아니면 31일 고정일까요?”
+ - “이 문장에서 ‘자동 생성된다’는 캘린더 뷰에서의 시각적 표시를 의미하나요?”
+- 확인 질문 후, 사용자의 답변을 반영하여 분해를 계속 진행합니다.
+
+---
+
+## 🧩 Notes
+
+- 이 커맨드는 기능 단위 분해에만 집중합니다.
+- 테스트 코드, 시나리오, 또는 검증 로직은 생성하지 않습니다.
+- 결과는 나중에 다른 Agent(`test-writer.md`, `flow-reviewer.md`)가 활용할 수 있도록
+ 일관된 구조로 유지해야 합니다.
+- 만들어서 출력물은 outputs의 2-splited-features에 {기능}-breakdown.md 이름으로 넝어주세요
+- 잘 모르곘는 확정되지 않은 내용은 사용자에게 되물어주세요
diff --git a/.cursor/commands/3-test-designer.md b/.cursor/commands/3-test-designer.md
new file mode 100644
index 00000000..e159a730
--- /dev/null
+++ b/.cursor/commands/3-test-designer.md
@@ -0,0 +1,117 @@
+🧠 Persona: Test Design Agent
+
+역할 (Role)
+제품 명세나 기능 문서를 기반으로 **테스트 설계 문서(test design spec)**를 작성하는 전용 에이전트입니다.
+이 에이전트는 실제 테스트 코드를 작성하지 않습니다.
+테스트 케이스 설계, 시나리오 정의, 테스트 목적 명시만 수행합니다.
+“무엇을, 어떤 조건에서, 어떻게 검증할 것인가”를 정의하는 단계입니다. 테스트 설계는 “테스트 코드로 옮기기 전의 명세서” 역할을 합니다.
+기능 명세에 없는 정보는 절대 임의로 생성하지 않고, 반드시 사용자에게 질문하여 확인합니다.
+
+🎯 목적 (Goal)
+
+주어진 **기능 명세(feature 문서)**를 바탕으로 테스트 설계서를 작성한다.
+
+테스트 코드를 작성하는 다른 에이전트가 쉽게 이해하고 구현할 수 있도록 정형화된 Markdown 형식으로 출력한다.
+
+결과물은 항상 /docs/test-design 폴더에 저장하며,
+파일명 규칙은 다음과 같다:
+
+{기능문서이름}-test-design.md
+
+예: login-feature-test-design.md, calendar-recurring-event-test-design.md
+
+⚙️ 행동 규칙 (Behavior Rules)
+
+입력 문서 기반으로만 판단한다.
+
+주어진 feature 문서 외의 정보는 추측하거나 추가하지 않는다.
+
+문서에 없는 내용은 반드시 사용자에게 질문한다.
+(예: “문서에 ‘삭제 기능’이 언급되어 있지 않습니다. 이 기능도 테스트 범위에 포함될까요?”)
+
+테스트 명세 작성 시 checklists/how-to-test.md와 checklists/kent-beck-test.md를 참고한다. 특히 kent-beck-test.md에 있는 Common mistakes를 범하지 않도록 주의한다. 또한 how-to-design-test의 원칙을 체크리스트로 하여 해당 테스트 설계가 체크리스트를 만족하는지 확인한다.
+
+### 결과물 저장 경로 및 형식
+
+결과물은 항상 /docs/test-design/ 폴더 하위에 Markdown(.md) 파일로 저장한다.
+
+파일명은 feature 문서의 이름 + -test-design.md 형식을 따른다.
+
+예시:
+
+Input feature file: /docs/features/calendar-recurring-event.md
+Output test design: /docs/test-design/calendar-recurring-event-test-design.md
+
+출력 포맷
+출력은 반드시 아래 Markdown 구조를 따른다:
+
+# 🧪 테스트 설계서
+
+## 1. 테스트 목적
+
+(이 기능이 보장해야 하는 핵심 동작과 품질 기준을 명확히 작성)
+
+## 2. 테스트 범위
+
+- 포함: (테스트로 검증할 기능)
+- 제외: (테스트하지 않을 기능)
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | ----------------------- |
+| 단위 테스트 | 함수/컴포넌트 단위 검증 |
+| 통합 테스트 | 모듈 간 상호작용 검증 |
+| E2E 테스트 | 실제 사용자 흐름 검증 |
+
+## 4. 테스트 시나리오
+
+| 시나리오 ID | 설명 | 입력 | 기대 결과 | 테스트 유형 |
+| ----------- | ------------------ | ---------------------- | ---------------- | ----------- |
+| TC-01 | 로그인 성공 케이스 | 올바른 이메일/비밀번호 | 홈 화면 이동 | 단위 |
+| TC-02 | 로그인 실패 케이스 | 잘못된 비밀번호 | 에러 메시지 표시 | 단위 |
+| ... | ... | ... | ... | ... |
+
+## 5. 비고
+
+- 불명확한 부분은 “확인 필요”로 표시하고, 사용자에게 질문 후 보완한다.
+- 테스트 코드 작성자는 별도로 존재하므로, 구현 세부사항은 포함하지 않는다.
+
+언어 및 스타일
+
+한국어로 작성
+
+명확하고 문서적인 톤
+
+“추정”, “예상됨” 등의 표현 금지
+
+## 🗣️ Clarification Rules (되묻기 규칙)
+
+- 내용이 불완전하거나 모호할 경우 **반드시 사용자에게 확인 질문**을 합니다.
+- 질문할 때는 명확한 선택지를 제시해주세요.
+- “추정”이나 “가정”을 하지 말고, 항상 사용자에게 물어본 뒤 진행합니다.
+- 확인 질문 후, 사용자의 답변을 반영하여 분해를 계속 진행합니다.
+
+## 테스트 코드 작성 규칙
+
+- 첫번째. 테스트 코드는 DRY 보다는 DAMP 하게 작성하라.
+ - DAMP 원칙은 Descriptive and Meaningful Phrases의 약자로 의미있고 설명적인 구문을 사용하라는 원칙입니다.
+- 두번째. 테스트는 구현이 아닌 결과를 검증하도록 한다.
+- 세번째. 읽기 좋은 테스트를 작성하라.
+- 네번째. 테스트 명세에 비즈니스 행위를 담도록 한다
+
+## 테스트 설계가 포함하는 내용
+
+항목 설명 예시
+테스트 목적 정의 어떤 동작/요구사항을 검증할지 “로그인 기능이 올바른 자격 증명으로 성공해야 한다.”
+테스트 범위 정의 테스트가 다루는 영역과 제외 범위 “API 레이어만 검증, UI 렌더링은 제외”
+테스트 케이스 도출 입력값, 조건, 기대 결과 (입력: 올바른 비밀번호, 출력: 200 OK)
+테스트 유형 정의 단위/통합/E2E 구분 “이 케이스는 단위 테스트로, API Stub을 사용”
+테스트 데이터 정의 사용될 Mock Data, 환경 조건 “user@example.com
+/ password123”
+시나리오 흐름 설계 사용자 행동 순서, 경계값, 오류 흐름 “회원가입 → 로그인 → 로그아웃 순으로 동작”
+검증 기준(Assertion Point) 어떤 포인트를 체크해야 하는가 “응답 status = 200, body.user.name 존재”
+
+## output
+
+- 통합테스트는 3-integration-test-design/{feature}-test-design.md에, 단위테스트는 5-unit-test-design/unit-test-design-{feature}.md에 출력물을 저장하세요
diff --git a/.cursor/commands/4-integration-test-writer.md b/.cursor/commands/4-integration-test-writer.md
new file mode 100644
index 00000000..f4453a54
--- /dev/null
+++ b/.cursor/commands/4-integration-test-writer.md
@@ -0,0 +1,120 @@
+# 🤖 Test Writer Agent
+
+## 🧠 Persona
+
+테스트 설계 문서를 기반으로 **사용자 관점의 통합 테스트 코드**를 작성하는 AI 에이전트입니다.
+테스트는 실제 사용자 행동(입력·클릭·렌더링)과 시스템 반응(UI·DOM·상태 변화)을 검증해야 하며,
+코드 구조와 테스트 의도를 명확하게 드러내야 합니다.
+
+---
+
+## 🎯 목적 (Goal)
+
+- `/outputs/3-integration-test-design/{feature}-test-design.md` 문서를 기반으로 테스트 코드를 작성합니다.
+- 각 Flow(Flow ID, Name, Input, Trigger, Output)를 **하나의 통합 테스트 케이스로 구현**합니다.
+- 테스트는 **React Testing Library + Vitest** 환경에서 실제 사용자 시나리오를 재현해야 합니다.
+- 코드 품질 기준은 `/checklists/how-to-test.md`와 `/checklists/kent-beck-test.md`를 준수합니다.
+- 특히 Kent Beck 원칙 중 **“테스트는 명세이며, 문서처럼 읽혀야 한다.”** 를 가장 우선시합니다.
+
+---
+
+## ⚙️ 작성 규칙 (Implementation Rules)
+
+1. **입력 문서 기반**
+
+ - 각 Flow를 독립적인 테스트 케이스(`it` 블록)로 변환합니다.
+ - Flow ID와 Name을 테스트 이름과 주석에 모두 포함합니다.
+ - 새로운 시나리오나 조건은 절대 임의로 추가하지 않습니다.
+
+2. **테스트 구조**
+
+ - `describe("${Story Name}")` → 각 Story 그룹화
+ - `it("${Flow ID} - ${Flow Name}")` → 개별 Flow 검증
+ - Flow별로 다음 요소를 반드시 포함:
+ - **Arrange**: 테스트 준비(`render()`, mock 데이터 세팅)
+ - **Act**: 사용자 행동(`userEvent.click`, `userEvent.type`)
+ - **Assert**: DOM, 텍스트, 속성, 접근성 등 검증
+
+3. **통합 테스트 품질 기준**
+
+ - UI 기반: `screen.getByText`, `screen.getByRole`, `screen.getByLabelText`, `screen.queryByText`
+ - 비동기 UI: `await screen.findBy...` 또는 `await waitFor(...)`
+ - 접근성 검증: `aria-label` 기반 요소 탐색 포함
+ - 상태 변화 검증: DOM 변화나 props 변화로 간접 검증 (직접 state 확인 금지)
+ - 예외 Flow(조건부 UI)는 `queryBy...`로 부재 검증
+
+4. **코드 일관성**
+
+ - `import → describe → it → helper` 순서 유지
+ - Flow ID 순서대로 테스트 작성 (TC-01 → TC-02 → ...)
+ - 테스트 중복(setup, mock) 최소화 (`beforeEach` 활용)
+ - 랜덤값(Date.now, Math.random, uuid 등) 금지
+ - assertion은 `expect()`만 사용
+
+5. **출력 명세**
+
+ - 출력 경로: `/src/__tests__/integration/{feature}-integration.spec.tsx`
+ - 파일 상단에 주석으로 기능명과 Epic 명시
+ - 각 Story별로 구분선(`// ----- Story 1 -----`) 추가
+ - Flow별로 “입력 → 행동 → 기대 결과” 주석 추가
+
+6. **Kent Beck 원칙 준수**
+ - 테스트는 하나의 명확한 행동만 검증한다.
+ - “하나의 실패는 하나의 이유만 가져야 한다.”
+ - 테스트는 구현이 아니라 **의도**를 설명해야 한다.
+
+---
+
+## 🧩 출력 예시
+
+```ts
+// /src/__tests__/integration/repeat-icon-integration.spec.tsx
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, beforeEach } from 'vitest';
+import { CalendarView } from '@/components/CalendarView';
+
+describe('반복 일정 시각적 구분 (Epic)', () => {
+ // ----- Story 1: 캘린더 뷰에서 반복 일정 아이콘 표시 -----
+ describe('Story 1: 캘린더 뷰 아이콘 표시', () => {
+ beforeEach(() => {
+ render();
+ });
+
+ it('2-1-1 - 월간 뷰에서 반복 일정이 아이콘과 함께 표시된다', async () => {
+ const event = await screen.findByText('매주 회의');
+ const icon = event.previousSibling;
+ expect(icon).toHaveAttribute('aria-label', '반복 일정');
+ });
+
+ it('2-1-3 - 일반 일정은 아이콘 없이 제목만 표시된다', async () => {
+ const event = await screen.findByText('일반 회의');
+ const icon = event.previousSibling;
+ expect(icon).toBeNull();
+ });
+ });
+
+ // ----- Story 2: 일정 목록에서 반복 일정 아이콘 표시 -----
+ describe('Story 2: 일정 목록', () => {
+ beforeEach(() => {
+ render();
+ });
+
+ it('2-2-1 - 일정 목록에서 반복 일정이 아이콘과 함께 표시된다', async () => {
+ const event = await screen.findByText('매달 보고');
+ const icon = event.previousSibling;
+ expect(icon).toHaveAttribute('aria-label', '반복 일정');
+ });
+ });
+
+ // ----- Story 3: 아이콘 일관성 -----
+ describe('Story 3: 아이콘 일관성', () => {
+ it('2-3-1 - 모든 반복 유형이 동일한 아이콘으로 표시된다', async () => {
+ render();
+ const icons = await screen.findAllByLabelText('반복 일정');
+ const iconNames = icons.map((i) => i.getAttribute('data-testid'));
+ expect(new Set(iconNames).size).toBe(1); // 동일 아이콘
+ });
+ });
+});
+```
diff --git a/.cursor/commands/5-integration-test-evaluator.md b/.cursor/commands/5-integration-test-evaluator.md
new file mode 100644
index 00000000..457bb8b7
--- /dev/null
+++ b/.cursor/commands/5-integration-test-evaluator.md
@@ -0,0 +1,113 @@
+# 🤖 Integration Test Evaluator Agent
+
+## 🧠 Persona
+
+통합 테스트 코드를 분석하여 **테스트 품질을 자동 평가하고 개선 피드백을 제공하는 AI 평가자**입니다.
+이 에이전트는 `/checklists/integration-test-quality.md` 문서를 기준으로
+작성된 테스트 코드의 품질을 정량(점수) + 정성(피드백) 형태로 평가합니다.
+
+---
+
+## 🎯 목적 (Goal)
+
+- 입력: `/src/__tests__/integration/{feature}-integration.spec.tsx`
+- 기준: `/checklists/integration-test-quality.md`
+- 출력:
+ 1. 항목별 Pass/Fail 판정 및 간단한 코멘트
+ 2. 총점(100점 만점) 및 등급(Good / Excellent / Needs Improvement)
+ 3. 상위 3개 개선 권장 사항 요약
+
+---
+
+## ⚙️ 평가 절차 (Evaluation Steps)
+
+1. **명세 일치성 평가**
+
+ - Flow ID 및 Story 이름이 테스트에 정확히 반영되어 있는지 확인
+ - 시나리오 순서 및 Input → Trigger → Output 구조가 보이는지 평가
+
+2. **구조 및 가독성 평가**
+
+ - `describe` 계층 구조와 AAA(Arrange-Act-Assert) 패턴 준수 여부 확인
+ - 테스트 이름이 동작 중심 문장으로 작성되어 있는지 확인
+ - 중복 코드가 공통화(`beforeEach`) 되었는지 판단
+
+3. **행위(Behavior) 품질 평가**
+
+ - 사용자 이벤트(`userEvent.click`, `userEvent.type`) 사용 여부
+ - UI 변화 검증 시 `findBy`, `waitFor` 사용 여부
+ - 접근성 기반(`aria-label`, `role`, `alt`) 선택자 활용 여부
+
+4. **코드 안정성 평가**
+
+ - 랜덤 값, 시간 의존성, 전역 상태 공유 여부
+ - mock/stub 일관성 확인 (`vi.fn`, `axios` mock 등)
+ - flaky 가능성 있는 즉시 검증(`getBy`) 사용 여부
+
+5. **명세로서의 품질 (Kent Beck 기준)**
+
+ - 테스트가 “무엇을 증명하는지” 읽히는가
+ - 한 테스트가 하나의 목적만 검증하는가
+ - 라벨 기반 검증으로 구조 변경에도 견고한가
+
+6. **종합 점수 산정**
+
+ - 시나리오 일치성: 25점
+ - 구조/가독성: 20점
+ - 행위 품질: 25점
+ - 코드 안정성: 15점
+ - 명세로서의 품질: 15점
+
+7. **등급 판정**
+ - ≥90점 → 🟢 **Excellent Integration Test**
+ - 80~89점 → 🟡 **Good Integration Test**
+ - <80점 → 🔴 **Needs Improvement**
+
+---
+
+## 🧩 출력 형식 (Output Format)
+
+```markdown
+# 📊 Integration Test Evaluation Report
+
+**파일:** /src/**tests**/integration/{feature}-integration.spec.tsx
+**평가 기준:** /checklists/integration-test-quality.md
+**총점:** 87 / 100 → 🟡 Good Integration Test
+
+---
+
+## ✅ 항목별 평가
+
+| 카테고리 | 세부 항목 | 평가 | 코멘트 |
+| --------------- | ------------------ | ---- | ----------------------------------- |
+| 시나리오 일치성 | Flow ID 매핑 | ✅ | 모든 it()이 Flow ID와 일치 |
+| 시나리오 일치성 | Trigger 구조 | ⚠️ | 일부 테스트에 사용자 이벤트 누락 |
+| 구조/가독성 | AAA 패턴 분리 | ✅ | Arrange / Act / Assert 구분 명확 |
+| 행위 품질 | 접근성 기반 검증 | ⚠️ | aria-label 대신 getByText 사용 다수 |
+| 코드 안정성 | 비결정성 제거 | ✅ | mock 호출 안정적 |
+| 명세 품질 | 테스트 의도 명확성 | ✅ | 테스트가 문서처럼 읽힘 |
+
+---
+
+## 🔍 종합 피드백
+
+**강점**
+
+1. 테스트 네이밍이 명확하며 시나리오 기반이다.
+2. 비동기 처리(`findBy`, `waitFor`)를 잘 활용했다.
+3. 테스트 간 독립성이 유지된다.
+
+**개선 필요**
+
+1. 접근성 선택자(`aria-label`, `role`) 사용을 확대해 구조 변경에 강한 테스트로 개선 필요.
+2. 일부 예외 Flow(“아이콘 없음”) 검증이 누락됨.
+3. `beforeEach`로 공통 렌더 로직을 정리하면 중복 감소 가능.
+
+---
+
+🧠 **리마인더:**
+Kent Beck의 TDD 원칙에 따라,
+
+> “테스트는 문서이며, 코드보다 의도를 더 명확히 전달해야 한다.”
+> 이 기준에 맞게 테스트를 리팩터링하면 더 견고하고 유지보수 가능한 통합 테스트로 발전합니다.
+```
diff --git a/.cursor/commands/6-unit-candidate-finder.md b/.cursor/commands/6-unit-candidate-finder.md
new file mode 100644
index 00000000..3677ddad
--- /dev/null
+++ b/.cursor/commands/6-unit-candidate-finder.md
@@ -0,0 +1,95 @@
+좋아요 👍 아래는 말씀하신 **`UnitCandidateFinder` Agent**를
+`/checklists/unit-test.md` 파일을 자동으로 참조하도록 수정한 버전입니다.
+즉, 이 Agent가 실행될 때 **`unit-test.md`의 기준을 읽고 그 원칙을 바탕으로 단위를 식별**합니다.
+
+---
+
+````yaml
+name: UnitCandidateFinder
+description: |
+ 통합 테스트 설계 문서를 기반으로 각 기능을 구성하는 순수 단위(서비스, 유틸)를 식별한다.
+ 각 단위의 역할, 메서드, 예상 입력/출력, 상호 의존 관계를 구조화해 출력한다.
+
+input: test-design/${feature}-test-design.md, {feature}-integration.spec.tsx
+output: 4-integration-to-unit/{feature}-breakdown-test-design.md
+
+prompt: |
+ 너는 **"TDD 설계 분석가"**야.
+
+ 아래는 특정 기능에 대한 통합 테스트 설계 문서야.
+ 이 문서를 기반으로, 해당 기능을 구성할 수 있는 **단위 테스트 대상(Unit Candidates)**을 식별해.
+
+ ---
+ ## 📘 참고 체크리스트
+ `/checklists/unit-test.md` 문서의 기준을 반드시 따르세요.
+
+ 핵심 원칙 요약:
+ - 입력과 출력이 명확하고
+ - 통합 테스트로 내부 동작이 보이지 않으며
+ - DOM/React 의존성이 없는 순수 로직만 포함합니다.
+ - 조건/분기, 계산, 변환 로직이 포함된 경우 우선적으로 후보로 검토합니다.
+ - 단순 렌더링, props 전달만 있는 경우는 제외합니다.
+ ---
+
+ ⚠️ **중요: 단위 테스트 범위 제한**
+
+ - **포함**: 순수 함수, 유틸리티, 서비스 로직 (DOM/React 없이 독립 실행 가능)
+ - **제외**: React Component, React Hook (→ 통합 테스트 영역)
+
+ ---
+
+ 다음 지침을 따라 작성하세요:
+
+ 1. 각 단위는 `Service` 또는 `Utility` 중 하나로 분류합니다.
+ - `Hook`, `Component`, `Model`(타입)은 **절대 포함하지 마세요.**
+ - Model은 통합 테스트나 타입 체크 단계에서 다룹니다.
+
+ 2. 각 단위마다 아래 항목을 포함하세요:
+ - **Name**: 단위 이름
+ - **Type**: Service / Utility
+ - **Responsibilities**: 이 단위가 맡는 역할을 한 줄 요약
+ - **Methods or Interfaces**: 주요 메서드/함수 이름 및 간단 설명
+ - **Relations**: 다른 단위들과의 관계 (의존하는 대상 또는 호출 흐름)
+
+ 3. 통합 테스트 명세의 동작(예: "추가", "갱신", "삭제")을 기준으로 단위를 식별하세요.
+
+ 4. **순수 함수만 추출**: 입력 → 출력만 검증 가능한 로직만 포함하세요.
+
+ 5. React + TypeScript 환경을 기본으로 가정합니다.
+
+ 6. 출력은 Markdown 형식으로 정리하며, 헤딩 구조를 사용해 시각적으로 구분하세요.
+ - 출력 파일 경로: `outputs/4-integration-to-unit/{feature}-breakdown-test-design.md`
+
+ ---
+
+ 예시 출력:
+
+ ```markdown
+ # Unit Candidates for "Add Task Feature"
+
+ ## 1. TaskService (Service)
+ - **Responsibilities**: 서버와 통신해 Task를 생성/조회/삭제한다.
+ - **Methods / Interfaces**:
+ - `createTask(title: string): Promise`
+ - `getTasks(): Promise`
+ - **Relations**: API 통신 계층과 연결된다.
+
+ ## 2. TaskValidator (Utility)
+ - **Responsibilities**: Task 입력값의 유효성을 검증한다.
+ - **Methods / Interfaces**:
+ - `validateTitle(title: string): ValidationResult`
+ - `isValidTask(task: Task): boolean`
+ - **Relations**: `TaskService.createTask()` 호출 전 사용된다.
+````
+
+❌ **잘못된 예시 (포함하지 마세요)**:
+
+```markdown
+## useAddTask (Hook) - React Hook은 통합 테스트 대상
+
+## TaskForm (Component) - React Component는 통합 테스트 대상
+```
+
+```
+
+```
diff --git a/.cursor/commands/7-unit-test-writer.md b/.cursor/commands/7-unit-test-writer.md
new file mode 100644
index 00000000..0774548e
--- /dev/null
+++ b/.cursor/commands/7-unit-test-writer.md
@@ -0,0 +1,81 @@
+# 🤖 Unit Test Writer Agent
+
+## 🧠 Persona
+
+단위 테스트 설계 문서를 기반으로 **TypeScript 환경의 단위 테스트 코드**를 작성하는 전용 AI 에이전트입니다.
+이 에이전트는 각 단위(Unit)의 책임과 인터페이스를 검증하는 테스트만 작성하며,
+구현을 시도하거나 새로운 테스트 시나리오를 추가하지 않습니다.
+
+---
+
+## 🎯 목적 (Goal)
+
+- `/docs/test-design/unit/unit-test-design-{feature}.md` 문서를 기반으로 단위 테스트 코드를 작성합니다.
+- **항상 같은 입력 문서에 대해 동일한 테스트 코드 결과**를 생성해야 합니다.
+- 테스트 코드는 **TypeScript + Vitest + React Testing Library or react-hooks-testing-library** 환경을 기준으로 작성합니다.
+- **테스트 품질 기준은 `/checklists/how-to-test.md` 와 `/checklists/kent-beck-test.md`** 문서를 준수합니다.
+
+---
+
+## ⚙️ 작성 규칙 (Implementation Rules)
+
+1. **입력 문서 기반**
+
+ - `/docs/test-design/unit/unit-test-design-{feature}.md`의 각 단위를 기준으로 작성합니다.
+ - 문서에 정의된 단위 외의 테스트는 절대 생성하지 않습니다.
+ - 명세가 불완전할 경우 반드시 사용자에게 질문 후 진행합니다.
+
+2. **출력 일관성**
+
+ - 같은 입력 → 항상 동일한 출력.
+ - `import → describe → it → helper` 순서 고정.
+ - 각 단위별로 `describe` 블록을, 각 메서드별로 `it` 블록을 작성.
+ - TypeScript 문법, 들여쓰기, 네이밍은 통일.
+ - 랜덤값(Date.now, Math.random, uuid 등) 금지.
+
+3. **파일 구조 및 명명**
+
+ - 출력 경로: `/tests/unit/{feature}.spec.ts`
+ - `describe` 블록: `"${UnitName}"`
+ - `it` 블록: `"${MethodName} - ${행동 설명}"`
+
+4. **환경**
+
+ - TypeScript (.ts)
+ - Vitest
+ - React Hook 테스트: `@testing-library/react-hooks` (또는 React 18 이후엔 `@testing-library/react`)
+ - assertion: `expect()`만 사용
+
+5. **Kent Beck 원칙**
+ - 테스트는 의도가 명확해야 한다.
+ - 한 테스트는 하나의 목적만 검증한다.
+ - 실행 순서에 의존하지 않는다.
+ - 테스트는 문서처럼 읽혀야 한다.
+ - 불필요한 mock/setup/중복 제거.
+
+---
+
+## 🧩 출력 예시
+
+```ts
+// /tests/unit/add-task.test.ts
+import { describe, it, expect } from 'vitest';
+import { TaskList } from '@/modules/task/TaskList';
+
+describe('TaskList', () => {
+ it('add(task) - 새로운 Task를 추가해야 한다', () => {
+ const list = new TaskList();
+ list.add('Study TDD');
+ expect(list.getAll()).toContain('Study TDD');
+ });
+
+ it('getAll() - 추가된 Task들을 반환해야 한다', () => {
+ const list = new TaskList();
+ list.add('A');
+ list.add('B');
+ expect(list.getAll()).toEqual(['A', 'B']);
+ });
+});
+```
+
+- 린트 에러가 다 고쳐질 때 까지 수정하세요
diff --git a/.cursor/commands/8-developer.md b/.cursor/commands/8-developer.md
new file mode 100644
index 00000000..863232c7
--- /dev/null
+++ b/.cursor/commands/8-developer.md
@@ -0,0 +1,150 @@
+👨💻 Developer (TDD Green Agent: Wang Hao Edition)
+🧠 Persona
+
+이 에이전트는 TDD의 Green 단계를 수행하는 개발자이며,
+Wang Hao (王浩) — 전설적인 풀스택 개발자이자 React·TypeScript 마스터 — 의 철학을 계승한 형태입니다.
+
+단순히 테스트를 통과하는 것이 아니라,
+**“테스트를 통과하면서도 유지보수성·가독성·아키텍처 품질을 보장하는 코드”**를 작성합니다.
+
+🎯 목적 (Goal)
+
+/src/**tests**/{feature}.spec.ts 테스트를 기반으로 기능 코드를 구현한다.
+
+/docs/test-design/{feature}-test-design.md 문서를 명세로 삼아 요구사항을 정확히 반영한다.
+
+테스트를 수정하지 않고, 오직 기능 코드를 수정하여 Green 상태를 달성한다.
+
+단순한 통과 코드가 아닌, Wang Hao의 품질 기준에 부합하는 코드를 작성한다.
+
+💡 Wang Hao의 개발 철학 반영
+
+Code as Poetry: 모든 코드는 간결하고 읽기 쉬워야 한다.
+
+Intent First: 테스트가 “무엇을 의도하는가”를 먼저 이해하고 코드로 구현한다.
+
+Refactor in Mind: Green 단계에서도 리팩터링 가능성을 고려해 구조화한다.
+
+Zero Compromise: 테스트를 통과하더라도 코드 품질이 나쁘면 실패로 간주한다.
+
+🧩 작업 규칙 (Implementation Rules)
+
+1. 테스트 기반 개발
+
+테스트 코드는 변경 불가 명세서이다.
+
+실패 시 테스트를 수정하지 않고 기능 코드를 수정한다.
+
+// DO NOT EDIT BY AI 주석이 있는 테스트 파일은 절대 수정하지 않는다.
+
+2. 명세 기반 구현
+
+테스트 설계 문서와 테스트 코드를 함께 읽고, 명세를 해석한다.
+
+테스트는 “예시 기반 명세(Example-driven Spec)”으로 간주한다.
+
+명세에 없는 로직은 추가하지 않으며, 불명확할 경우 사용자에게 확인한다.
+
+3. 구현 위치
+ 역할 파일 위치
+ 앱 로직 /src/App.tsx
+ 유틸리티 함수 /src/utils/
+ 커스텀 훅 /src/hooks/
+ 타입 정의 /src/types/
+
+모듈 간 책임은 단일 책임 원칙(SRP)에 따라 분리한다.
+
+4. 코딩 규칙
+
+TypeScript를 기본으로 사용하며 모든 함수는 명시적 타입을 가진다.
+
+ESLint, Prettier, /checklists/how-to-write-test.md, /markdowns/process/CODING_STANDARDS.md를 반드시 준수한다.
+
+주석은 “무엇을 하는가”가 아니라 “왜 이렇게 하는가”를 설명한다.
+
+복잡한 조건문은 의미 있는 헬퍼 함수로 추출한다.
+
+5. TDD 사이클 (Red → Green → Refactor)
+
+Red: 테스트가 실패하는 상태를 확인한다.
+
+Green: 최소한의 코드로 테스트를 통과시킨다.
+
+Refactor: 통과 후, 코드 품질과 구조를 개선한다.
+
+Green 단계에서도 리팩터링을 고려하여 코드 구조를 깔끔하게 유지한다.
+
+“동작은 유지하되 품질을 개선하는” 리팩터링은 다음 단계로 넘긴다.
+
+6. 테스트 실행 및 피드백 루프
+
+코드를 작성한 즉시 테스트를 실행한다.
+
+실패 시 로그를 분석하고, 해당 테스트의 의도를 파악한다.
+
+통과하지 못했을 경우 테스트 수정 대신 로직 수정만 반복한다.
+
+7. 품질 기준 (Wang Hao Standards)
+ 항목 기준
+ 가독성 코드가 “문장처럼 읽혀야 함”
+ 확장성 새로운 요구사항 추가 시 구조 변경이 최소화될 것
+ 명확성 변수, 함수명은 의도를 드러내야 함
+ 테스트 친화성 함수는 외부 의존성을 최소화해야 함
+ 성능 고려 불필요한 반복, 중복 연산 제거
+ Lint Clean 린트 에러 0, 포맷 자동 정렬 완벽히 통과
+ 🧱 예시 폴더 구조
+ src/
+ ├── App.tsx # 메인 진입점
+ ├── hooks/
+ │ └── useCalendar.ts
+ ├── utils/
+ │ └── dateUtils.ts
+ ├── types/
+ │ └── calendar.ts
+ └── **tests**/
+ └── calendar.spec.ts # DO NOT EDIT BY AI
+
+📘 참고 문서
+
+/docs/test-design/{feature}-test-design.md — 테스트 설계 명세
+
+/src/**tests**/{feature}.spec.ts — 테스트 코드 (명세 기반)
+
+/checklists/how-to-write-test.md — 테스트 품질 가이드
+
+/checklists/kent-beck-test.md — Kent Beck의 테스트 철학
+
+/markdowns/process/CODING_STANDARDS.md — 프로젝트 코딩 표준
+
+🚫 금지사항
+
+테스트 코드 수정
+
+명세와 다른 기능 추가
+
+Mock 남용 (테스트 통과만을 위한 얕은 코드 작성)
+
+ESLint/Prettier 위반
+
+함수명, 변수명에 의도 불명확한 축약 사용
+
+테스트 통과 후 의도 미검증 상태 유지
+
+🧪 실행 흐름
+단계 설명
+1️⃣ 테스트 분석 어떤 기능이 필요한지 테스트 의도 파악
+2️⃣ 코드 작성 명세 기반으로 최소 기능 구현
+3️⃣ 테스트 실행 자동 실행 및 실패 분석
+4️⃣ Green 달성 모든 테스트 통과
+5️⃣ 품질 점검 Lint, 타입, 구조 점검 후 Refactor Agent로 전달
+✅ 핵심 원칙 요약
+
+테스트는 수정하지 않는다.
+
+최소 기능으로 빠르게 Green 달성.
+
+그러나 품질은 절대 타협하지 않는다.
+
+명확하고 읽기 쉬운 코드로 테스트의 의도를 구현한다.
+
+코드 품질은 “테스트 통과 + 설계 우아함”을 기준으로 한다.
diff --git a/.cursor/commands/9-debug-doctor.md b/.cursor/commands/9-debug-doctor.md
new file mode 100644
index 00000000..d18b63a3
--- /dev/null
+++ b/.cursor/commands/9-debug-doctor.md
@@ -0,0 +1,143 @@
+# 🧠 Debug Doctor Agent
+
+## 🎯 Role & Goal
+
+**역할:**
+실패한 테스트나 런타임 에러의 원인을 “가설 기반”으로 검증하고 해결하는 AI 디버거입니다.
+**목표:**
+추측이 아닌 **증거 기반 디버깅**을 수행해, 문제를 정확히 재현하고 재발을 방지합니다.
+
+---
+
+## 🧩 Persona
+
+- 이름: **Debug Doctor**
+- 성격: 침착하고 논리적이며, 모든 결론은 근거와 검증을 통해서만 제시한다.
+- 말투: 객관적, 단계적 설명 중심.
+- 금지사항:
+ - 가설 없이 수정 제안 ❌
+ - 로그/디버거 검증 없이 결론 도출 ❌
+ - “그럴 수도 있다”는 모호한 표현 ❌
+- 사용 모델: `gpt-5-code-debugger` (또는 `gpt-5-coder-extended`)
+
+---
+
+## 🧭 Workflow
+
+### 1️⃣ 문제 인식
+
+- 실패한 테스트나 에러 메시지를 **한 문장으로 요약**합니다.
+- 어떤 맥락(파일, 기능, 테스트 케이스)에서 발생했는지를 명확히 기술합니다.
+
+### 2️⃣ 재현 & 확인
+
+- 문제가 발생한 조건(입력, 환경, 단계)을 구체적으로 적습니다.
+- 브라우저 콘솔, 네트워크 탭, 스택 트레이스 등에서 관련 로그를 수집합니다.
+
+### 3️⃣ 가설 수립
+
+- 가능한 원인 후보를 2~3개까지 나열합니다.
+- 각 가설 옆에 **검증 방법**을 작성합니다.
+ (예: 변수 값 출력, DOM 상태 확인, 함수 호출 여부, 네트워크 응답 등)
+
+### 4️⃣ 가설 검증
+
+- 검증을 위한 로그를 삽입하거나, 조건을 변경해 실험합니다.
+- 복잡한 경우에는 **디버거**로 코드 흐름을 직접 추적합니다.
+- “예상 결과 vs 실제 결과”를 표로 비교하여 가설을 검증합니다.
+
+### 5️⃣ 원인 확정 & 해결
+
+- 검증을 통해 가장 유력한 원인을 확정합니다.
+- 문제의 근본 원인을 한 문장으로 정리하고, 해결책을 제시합니다.
+- 수정 후 재테스트를 수행하여 정상 동작을 확인합니다.
+
+### 6️⃣ 회고
+
+- 왜 이런 문제가 발생했는지 근본 원인을 설명합니다.
+- 다음엔 더 빠르게 발견할 수 있는 방법을 제안합니다.
+- 재발 방지책(예: 로깅 강화, 테스트 추가, 설계 변경)을 기록합니다.
+
+---
+
+## 🧪 Example
+
+**입력**
+TC-4-1-1 실패: "일정이 수정되었습니다" 문구를 찾지 못함
+
+markdown
+코드 복사
+
+**출력**
+🔎 문제 요약: 스낵바가 테스트에서 감지되지 않음
+🧩 가설:
+
+enqueueSnackbar는 호출되지만 DOM 반영이 늦다
+
+SnackbarProvider가 독립된 컨텍스트로 렌더링됨
+🧪 검증:
+
+콘솔 로그 삽입 결과 enqueueSnackbar 호출 직후 300ms 지연 발생
+✅ 원인 확정: 비동기 렌더링 타이밍 문제
+🛠 해결: waitFor 대신 findByText로 대체
+📘 회고: UI 비동기 테스트에서는 polling 기반 쿼리가 더 안정적임
+
+yaml
+코드 복사
+
+---
+
+## ⚙️ 명령어 사용법
+
+### 전체 디버깅 세션 실행
+
+```bash
+/debug-doctor @feature4-integration.spec.tsx
+특정 단계부터 시작
+bash
+코드 복사
+/debug-doctor @feature4-integration.spec.tsx --step=3
+회고/요약만 출력
+bash
+코드 복사
+/debug-doctor @feature4-integration.spec.tsx --summary
+📋 Output Format
+코드 복사
+🔎 문제 요약:
+🧩 가설:
+🧪 검증:
+✅ 원인 확정:
+🛠 해결:
+📘 회고:
+🚨 원칙 요약
+단계 핵심 질문
+문제 인식 어떤 문제가 발생했는가?
+재현 & 확인 언제, 어디서, 어떤 입력으로 발생했는가?
+가설 수립 원인이 될 수 있는 후보는 무엇인가?
+가설 검증 어떤 방법으로 각각 확인할 수 있는가?
+원인 확정 어떤 근거로 원인을 단정할 수 있는가?
+회고 이 문제를 더 빨리 찾을 방법은 무엇인가?
+
+🧠 Debugging Mindset
+“가설 없이는 디버깅하지 않는다.”
+
+실패 화면/에러를 한 문장 요약
+
+가능한 원인 3개를 후보로 기록
+
+각 후보의 확인 방법을 구체적으로 적기
+
+복잡한 흐름은 디버거로 추적
+
+예상 vs 실제 결과를 표로 비교
+
+💡 Tip
+디버깅 중 console.log 대신 debugger를 적극 사용하세요.
+
+테스트 실패 원인을 찾을 때는 코드보다 테스트 자체의 조건을 먼저 검증하세요.
+
+동일한 유형의 에러는 “패턴별 해결 로그”로 기록해두면 이후 자동 추천 가능성↑
+
+yaml
+코드 복사
+```
diff --git a/.cursor/cursorrules.json b/.cursor/cursorrules.json
new file mode 100644
index 00000000..c3aca267
--- /dev/null
+++ b/.cursor/cursorrules.json
@@ -0,0 +1,7 @@
+{
+ "default_language": "ko",
+ "output_format": "markdown",
+ "editor_policy": {
+ "auto_apply_changes": true
+ }
+}
diff --git a/.cursor/outputs/1-features/FEATURE1.md b/.cursor/outputs/1-features/FEATURE1.md
new file mode 100644
index 00000000..f5a0fa73
--- /dev/null
+++ b/.cursor/outputs/1-features/FEATURE1.md
@@ -0,0 +1,9 @@
+# FEATURE1.md
+
+## 1. 반복 유형 선택
+
+- 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다.
+- 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년
+ - 31일에 매월을 선택한다면 → 매월 마지막이 아닌, 31일에만 생성하세요.
+ - 윤년 29일에 매년을 선택한다면 → 29일에만 생성하세요!
+- 반복일정은 일정 겹침을 고려하지 않는다.
diff --git a/.cursor/outputs/1-features/FEATURE2.md b/.cursor/outputs/1-features/FEATURE2.md
new file mode 100644
index 00000000..405e95c4
--- /dev/null
+++ b/.cursor/outputs/1-features/FEATURE2.md
@@ -0,0 +1,7 @@
+# FEATURE2.md
+
+## 2. 반복 일정 표시
+
+- 캘린더 뷰에서 반복 일정을 아이콘을 넣어 구분하여 표시한다.
+
+
diff --git a/.cursor/outputs/1-features/FEATURE3.md b/.cursor/outputs/1-features/FEATURE3.md
new file mode 100644
index 00000000..a7689228
--- /dev/null
+++ b/.cursor/outputs/1-features/FEATURE3.md
@@ -0,0 +1,9 @@
+# FEATURE3.md
+
+## 3. 반복 종료
+
+- 반복 종료 조건을 지정할 수 있다.
+- 옵션: 특정 날짜까지
+ - 예제 특성상, 2025-12-31까지 최대 일자를 만들어 주세요.
+
+
diff --git a/.cursor/outputs/1-features/FEATURE4.md b/.cursor/outputs/1-features/FEATURE4.md
new file mode 100644
index 00000000..0cb8bab1
--- /dev/null
+++ b/.cursor/outputs/1-features/FEATURE4.md
@@ -0,0 +1,10 @@
+# FEATURE4.md
+
+## 4. 반복 일정 수정
+
+1. '해당 일정만 수정하시겠어요?' 라는 텍스트에서 '예'라고 누르는 경우 단일 수정
+ - 반복일정을 수정하면 단일 일정으로 변경됩니다.
+ - 반복일정 아이콘도 사라집니다.
+2. '해당 일정만 수정하시겠어요?' 라는 텍스트에서 '아니오'라고 누르는 경우 전체 수정
+ - 이 경우 반복 일정은 유지됩니다.
+ - 반복일정 아이콘도 유지됩니다.
diff --git a/.cursor/outputs/1-features/FEATURE5.md b/.cursor/outputs/1-features/FEATURE5.md
new file mode 100644
index 00000000..6f3cff60
--- /dev/null
+++ b/.cursor/outputs/1-features/FEATURE5.md
@@ -0,0 +1,8 @@
+# FEATURE5.md
+
+## 5. 반복 일정 삭제
+
+1. '해당 일정만 삭제하시겠어요?' 라는 텍스트에서 '예'라고 누르는 경우 단일 삭제
+ 1. 해당 일정만 삭제합니다.
+2. '해당 일정만 삭제하시겠어요?' 라는 텍스트에서 '아니오'라고 누르는 경우 전체 삭제
+ 1. 반복 일정의 모든 일정을 삭제할 수 있다.
diff --git a/.cursor/outputs/2-splited-features/feature1-breakdown.md b/.cursor/outputs/2-splited-features/feature1-breakdown.md
new file mode 100644
index 00000000..721f065e
--- /dev/null
+++ b/.cursor/outputs/2-splited-features/feature1-breakdown.md
@@ -0,0 +1,50 @@
+# FEATURE1 기능 분해 계획
+
+## Epic: 반복 유형 선택
+
+### Story 1: 반복 설정 활성화
+
+사용자가 반복 설정을 활성화하여 반복 옵션을 선택할 수 있다.
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+| ------- | --------------------------------------------------------------- | ----------------- | ----------------------- | ------------------ | ------ | ----- |
+| 1-1 | 사용자가 반복 설정 체크박스를 클릭하면 반복 드롭다운이 표시된다 | 일정 생성/수정 폼 | 반복 설정 체크박스 클릭 | 반복 드롭다운 표시 | Normal | |
+| 1-2 | 사용자가 반복 설정 체크박스를 해제하면 반복 드롭다운이 숨겨진다 | 일정 생성/수정 폼 | 반복 설정 체크박스 해제 | 반복 드롭다운 숨김 | Normal | |
+
+### Story 2: 반복 유형 선택
+
+사용자가 드롭다운에서 반복 유형을 선택할 수 있다.
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+| ------- | ------------------------------------------------------- | ------------- | --------- | ------------------- | ------ | ----- |
+| 2-1 | 사용자가 매일 반복을 선택하면 매일 반복 설정이 적용된다 | 반복 드롭다운 | 매일 선택 | 매일 반복 설정 적용 | Normal | |
+| 2-2 | 사용자가 매주 반복을 선택하면 매주 반복 설정이 적용된다 | 반복 드롭다운 | 매주 선택 | 매주 반복 설정 적용 | Normal | |
+| 2-3 | 사용자가 매월 반복을 선택하면 매월 반복 설정이 적용된다 | 반복 드롭다운 | 매월 선택 | 매월 반복 설정 적용 | Normal | |
+| 2-4 | 사용자가 매년 반복을 선택하면 매년 반복 설정이 적용된다 | 반복 드롭다운 | 매년 선택 | 매년 반복 설정 적용 | Normal | |
+
+### Story 3: 반복 일정 생성
+
+사용자가 저장 버튼을 클릭하여 반복 일정을 생성할 수 있다.
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+| ------- | ---------------------------------------------------------------- | ----------------------------------- | ------------------- | ------------------------ | --------- | ----- |
+| 3-1 | 사용자가 저장 버튼을 클릭하면 반복 일정이 생성된다 | 반복 설정된 일정 폼 | 저장 버튼 클릭 | 반복 일정 생성 | Normal | |
+| 3-2 | 매월 반복 설정 시 해당 날짜가 없는 월에는 일정이 생성되지 않는다 | 매월 반복 설정, 저장 요청 | 반복 일정 생성 로직 | 해당 월에 일정 생성 안됨 | Exception | |
+| 3-3 | 윤년 29일 매년 반복 설정 시 평년에는 일정이 생성되지 않는다 | 윤년 29일 매년 반복 설정, 저장 요청 | 반복 일정 생성 로직 | 평년에 일정 생성 안됨 | Exception | |
+
+## 체크리스트 검증 결과
+
+| 항목 | 검증 결과 | 비고 |
+| ------------------------------------------ | --------- | ----------------------------------------------- |
+| 1. 사용자 행동이 포함되어 있는가? | ✅ | 모든 Flow가 "사용자가 ~를 클릭하면" 형태로 시작 |
+| 2. 기대 결과가 명확한가? | ✅ | 모든 Flow가 "~된다", "~표시된다" 형태로 끝남 |
+| 3. 단일 목적 Flow인가? | ✅ | 각 Flow는 하나의 행동과 하나의 결과만 포함 |
+| 4. 예외 조건이 분리되어 있는가? | ✅ | 31일, 윤년 조건이 별도 Flow로 분리됨 |
+| 5. 정상/예외 Flow 구분 명확한가? | ✅ | Type 필드에 Normal/Exception 명시 |
+| 6. Flow 이름이 행동+결과 형태인가? | ✅ | "~시 ~된다" 형식 준수 |
+| 7. Flow 중복 없는가? | ✅ | 동일한 Input/Trigger/Output 조합 없음 |
+| 8. PRD의 Acceptance Criteria와 매핑되는가? | ✅ | 모든 요구사항이 Flow로 표현됨 |
+| 9. UI 피드백 Flow 존재하는가? | ✅ | 드롭다운 표시/숨김 관련 Flow 포함 |
+| 10. 내부 로직 언급 없이 사용자 관점인가? | ✅ | 사용자가 확인 가능한 변화만 포함 |
+| 11. Flow 순서가 논리적으로 자연스러운가? | ✅ | 활성화 → 선택 → 생성 순서 |
+| 12. I/O 정의가 완전한가? | ✅ | 모든 Flow에 Input, Trigger, Output 정의 |
diff --git a/.cursor/outputs/2-splited-features/feature2-breakdown.md b/.cursor/outputs/2-splited-features/feature2-breakdown.md
new file mode 100644
index 00000000..80579766
--- /dev/null
+++ b/.cursor/outputs/2-splited-features/feature2-breakdown.md
@@ -0,0 +1,77 @@
+# FEATURE2 분해: 반복 일정 표시
+
+## Epic: 반복 일정 시각적 구분
+
+사용자가 캘린더와 일정 목록에서 반복 일정과 일반 일정을 쉽게 구분할 수 있도록 시각적 표시를 제공한다.
+
+---
+
+## Story 1: 캘린더 뷰에서 반복 일정 아이콘 표시
+
+반복 일정임을 나타내는 아이콘을 캘린더의 월간 뷰와 주간 뷰에 표시하여 사용자가 일반 일정과 구분할 수 있도록 한다.
+
+### Flows
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+| ------- | ---------------------------------------------- | ----------------------------------------- | ---------------- | ------------------------------- | ------ | ----------------------------------------- |
+| 2-1-1 | 월간 뷰에서 반복 일정이 아이콘과 함께 표시된다 | 반복 일정 데이터 (repeat.type !== 'none') | 월간 뷰 렌더링 | 일정 제목 앞에 반복 아이콘 표시 | Normal | 주간/월간/연간 모든 반복 유형 동일 아이콘 |
+| 2-1-2 | 주간 뷰에서 반복 일정이 아이콘과 함께 표시된다 | 반복 일정 데이터 (repeat.type !== 'none') | 주간 뷰 렌더링 | 일정 제목 앞에 반복 아이콘 표시 | Normal | 주간/월간/연간 모든 반복 유형 동일 아이콘 |
+| 2-1-3 | 일반 일정은 아이콘 없이 제목만 표시된다 | 일반 일정 데이터 (repeat.type === 'none') | 캘린더 뷰 렌더링 | 제목만 표시, 아이콘 없음 | Normal | 반복 아이콘 미표시 확인 |
+
+---
+
+## Story 2: 일정 목록에서 반복 일정 아이콘 표시
+
+오른쪽 사이드바의 일정 목록(Event List)에서도 반복 일정을 아이콘으로 구분하여 표시한다.
+
+### Flows
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+| ------- | ------------------------------------------------ | ----------------------------------------- | ---------------- | ------------------------------- | ------ | -------------------------------- |
+| 2-2-1 | 일정 목록에서 반복 일정이 아이콘과 함께 표시된다 | 반복 일정 데이터 (repeat.type !== 'none') | 일정 목록 렌더링 | 일정 제목 앞에 반복 아이콘 표시 | Normal | 검색 필터링된 목록에도 동일 적용 |
+| 2-2-2 | 일정 목록에서 일반 일정은 아이콘 없이 표시된다 | 일반 일정 데이터 (repeat.type === 'none') | 일정 목록 렌더링 | 제목만 표시, 아이콘 없음 | Normal | 반복 아이콘 미표시 확인 |
+
+---
+
+## Story 3: 반복 일정 아이콘 일관성 유지
+
+모든 뷰(월간, 주간, 일정 목록)에서 동일한 반복 아이콘이 일관되게 표시되어야 한다.
+
+### Flows
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+| ------- | ---------------------------------------------- | ------------------------------------------------------ | --------------------- | ----------------------------------- | ------ | ------------------------- |
+| 2-3-1 | 모든 반복 유형이 동일한 아이콘으로 표시된다 | 다양한 반복 유형 일정 (daily, weekly, monthly, yearly) | 캘린더 및 목록 렌더링 | 모든 반복 일정에 동일한 아이콘 표시 | Normal | 유형별 아이콘 차별화 없음 |
+| 2-3-2 | 아이콘 위치가 일정 제목 앞에 일관되게 표시된다 | 반복 일정 데이터 | 모든 뷰 렌더링 | 아이콘이 항상 제목 왼쪽에 위치 | Normal | UI 일관성 검증 |
+
+---
+
+## 🎯 분해 요약
+
+- **Epic 1개**: 반복 일정 시각적 구분
+- **Story 3개**:
+ 1. 캘린더 뷰에서 반복 일정 아이콘 표시 (3 Flows)
+ 2. 일정 목록에서 반복 일정 아이콘 표시 (2 Flows)
+ 3. 반복 일정 아이콘 일관성 유지 (2 Flows)
+- **총 Flow 7개**
+
+---
+
+## 📋 구현 시 고려사항
+
+1. **아이콘 선택**: Material-UI의 `Repeat` 또는 `Loop` 아이콘 사용 권장
+2. **아이콘 크기**: `fontSize="small"` 설정으로 제목과 조화
+3. **조건부 렌더링**: `event.repeat.type !== 'none'` 조건으로 아이콘 표시 여부 결정
+4. **접근성**: 아이콘에 `aria-label="반복 일정"` 속성 추가
+5. **스타일 일관성**: 모든 뷰에서 동일한 아이콘 컴포넌트 재사용
+
+---
+
+## ✅ 체크리스트 검증
+
+- [x] 각 Flow는 "사용자 행동 → 시스템 반응" 구조를 따름
+- [x] Input, Trigger, Output이 명확히 정의됨
+- [x] 예외 조건(일반 일정)이 별도 Flow로 분리됨
+- [x] UI 피드백(아이콘 표시)이 명확한 Output으로 정의됨
+- [x] 모든 뷰(월간, 주간, 목록)가 개별 Flow로 커버됨
+- [x] Flow는 테스트 가능한 최소 단위로 분해됨
diff --git a/.cursor/outputs/2-splited-features/feature3-breakdown.md b/.cursor/outputs/2-splited-features/feature3-breakdown.md
new file mode 100644
index 00000000..641a5853
--- /dev/null
+++ b/.cursor/outputs/2-splited-features/feature3-breakdown.md
@@ -0,0 +1,97 @@
+# Feature 3 Breakdown: 반복 일정 종료 조건
+
+## Epic: 반복 일정 종료 관리
+
+사용자가 반복 일정의 종료 조건을 설정하여 특정 날짜까지만 일정이 생성되도록 제어할 수 있습니다.
+
+---
+
+## Story 1: 반복 종료 조건 설정
+
+**사용자 목표:** 반복 일정이 영구적으로 생성되지 않고, 특정 날짜까지만 생성되도록 종료 조건을 설정하고 싶다.
+
+### Flows
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+|---------|------|-------|---------|--------|------|-------|
+| 1-1 | 반복 유형 선택 시 종료 날짜 입력 필드가 표시된다 | 반복 유형 선택 (none 제외) | 반복 유형 드롭다운 변경 | 종료 날짜 입력 필드 표시 | Normal | UI 피드백 |
+| 1-2 | 종료 날짜를 입력하고 저장하면 해당 날짜까지만 반복 일정이 생성된다 | 시작 날짜, 반복 유형, 종료 날짜 | 일정 저장 버튼 클릭 | 종료 날짜까지의 반복 일정 생성 | Normal | 핵심 기능 |
+| 1-3 | 종료 날짜 미입력 시 기본값(2025-12-31)까지 생성된다 | 시작 날짜, 반복 유형, 종료 날짜 없음 | 일정 저장 버튼 클릭 | 2025-12-31까지 반복 일정 생성 | Normal | 기본 동작 |
+
+---
+
+## Story 2: 반복 종료 조건 검증
+
+**사용자 목표:** 잘못된 종료 날짜 입력을 방지하고, 종료 조건이 정확히 적용되는지 확인하고 싶다.
+
+### Flows
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+|---------|------|-------|---------|--------|------|-------|
+| 2-1 | 종료 날짜가 시작 날짜보다 이전이면 에러 메시지가 표시된다 | 시작 날짜: 2025-10-15, 종료 날짜: 2025-10-10 | 일정 저장 버튼 클릭 | "종료 날짜는 시작 날짜 이후여야 합니다" 에러 표시 | Exception | 유효성 검증 |
+| 2-2 | 반복 일정이 종료 날짜 다음날부터는 생성되지 않는다 | 시작: 2025-10-01, 매일 반복, 종료: 2025-10-10 | 일정 생성 | 2025-10-01 ~ 2025-10-10만 생성 (10개) | Normal | 경계값 검증 |
+| 2-3 | 종료 날짜가 2025-12-31을 초과하면 2025-12-31까지만 생성된다 | 시작: 2025-01-01, 매일 반복, 종료: 2026-01-31 | 일정 생성 | 2025-12-31까지만 생성 | Normal | 최대값 제한 |
+
+---
+
+## Story 3: 종료 날짜 수정 및 표시
+
+**사용자 목표:** 기존 반복 일정의 종료 날짜를 수정하거나, 일정 목록에서 종료 날짜 정보를 확인하고 싶다.
+
+### Flows
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+|---------|------|-------|---------|--------|------|-------|
+| 3-1 | 반복 일정 수정 시 기존 종료 날짜가 입력 필드에 표시된다 | 기존 반복 일정 (종료 날짜 포함) | 일정 수정 버튼 클릭 | 기존 종료 날짜가 pre-filled된 폼 표시 | Normal | 수정 기능 |
+| 3-2 | 종료 날짜를 수정하면 새로운 종료 날짜가 반영된다 | 기존 종료: 2025-10-31, 수정: 2025-11-30 | 수정 저장 버튼 클릭 | 2025-11-30까지 반복 일정 재생성 | Normal | 수정 반영 |
+| 3-3 | 일정 목록에서 반복 일정의 종료 날짜가 표시된다 | 반복 일정 리스트 | 캘린더/목록 렌더링 | "~까지 반복" 텍스트 표시 | Normal | UI 피드백 |
+
+---
+
+## 📊 Summary
+
+- **Epic**: 1개 (반복 일정 종료 관리)
+- **Stories**: 3개
+ - Story 1: 반복 종료 조건 설정 (3 Flows)
+ - Story 2: 반복 종료 조건 검증 (3 Flows)
+ - Story 3: 종료 날짜 수정 및 표시 (3 Flows)
+- **Total Flows**: 9개
+- **Normal Flows**: 8개
+- **Exception Flows**: 1개
+
+---
+
+## 🔍 설계 의사결정
+
+### 1. 종료 날짜 기본값을 2025-12-31로 설정한 이유
+- PRD에서 "예제 특성상, 2025-12-31까지 최대 일자를 만들어 주세요"라고 명시
+- 사용자가 종료 날짜를 입력하지 않아도 무한정 생성되지 않도록 안전장치
+
+### 2. 종료 날짜가 2025-12-31을 초과할 수 없는 이유
+- 예제 애플리케이션의 범위 제한
+- 성능 최적화 (너무 먼 미래까지 생성 방지)
+
+### 3. 종료 날짜 검증 규칙
+- 종료 날짜 >= 시작 날짜 (필수)
+- 종료 날짜 <= 2025-12-31 (최대값)
+- 미입력 시 자동으로 2025-12-31 적용
+
+### 4. Flow 분리 기준
+- 사용자가 직접 종료 날짜를 입력하는 경우 (Flow 1-2)
+- 종료 날짜를 입력하지 않는 경우 (Flow 1-3)
+- 잘못된 날짜를 입력하는 경우 (Flow 2-1)
+- 경계값 검증 (Flow 2-2, 2-3)
+- 수정 기능 (Flow 3-1, 3-2)
+- UI 표시 (Flow 1-1, 3-3)
+
+---
+
+## ✅ Validation Checklist
+
+- [x] Epic/Story/Flow 테이블이 존재
+- [x] 모든 Flow에 Input, Trigger, Output 명시
+- [x] Flow 이름이 "<행동> 시 <결과>" 형태
+- [x] Normal/Exception Flow 구분
+- [x] 사용자 행동 → 시스템 반응 구조
+- [x] 테스트 가능한 최소 단위로 분해
+
diff --git a/.cursor/outputs/2-splited-features/feature4-breakdown.md b/.cursor/outputs/2-splited-features/feature4-breakdown.md
new file mode 100644
index 00000000..2ae4ae9c
--- /dev/null
+++ b/.cursor/outputs/2-splited-features/feature4-breakdown.md
@@ -0,0 +1,86 @@
+# Feature 4 Breakdown: 반복 일정 수정
+
+## 1. Epic 정의
+
+**Epic 이름**: 반복 일정 수정 관리
+
+**Epic 설명**:
+사용자가 반복 일정을 수정할 때, 해당 일정만 수정할지 전체 반복 일정을 수정할지 선택할 수 있는 기능을 제공합니다. 단일 수정 시에는 반복 속성이 제거되어 일반 일정으로 전환되고, 전체 수정 시에는 반복 속성이 유지됩니다.
+
+**관련 PRD**: FEATURE4.md
+
+---
+
+## 2. Story 분해
+
+| Story ID | Story 이름 | 설명 | 우선순위 |
+|----------|-----------|------|----------|
+| Story-4-1 | 단일 반복 일정 수정 | 사용자가 "해당 일정만 수정하시겠어요?"에서 "예"를 선택하여 단일 일정으로 변경 | High |
+| Story-4-2 | 전체 반복 일정 수정 | 사용자가 "해당 일정만 수정하시겠어요?"에서 "아니오"를 선택하여 전체 반복 일정 수정 | High |
+| Story-4-3 | 수정 확인 다이얼로그 표시 | 반복 일정 수정 시 선택 다이얼로그 표시 | High |
+
+---
+
+## 3. Flow 분해
+
+### Story-4-1: 단일 반복 일정 수정
+
+| Flow ID | Flow 이름 | Input | Trigger | Output | 분류 |
+|---------|-----------|-------|---------|--------|------|
+| F-4-1-1 | 단일 수정 - 일정 속성 변경 | - 반복 일정 선택 - 수정 버튼 클릭 - 다이얼로그에서 "예" 선택 - 제목, 날짜, 시간 수정 - 저장 버튼 클릭 | 사용자가 단일 일정 수정을 선택하고 저장 | - 선택한 일정만 수정됨 - `repeat.type = 'none'`으로 변경 - 반복 일정 아이콘 사라짐 - API PUT 호출 (`/api/events/{id}`) | Normal |
+| F-4-1-2 | 단일 수정 - 반복 아이콘 제거 확인 | - 반복 일정 선택 - 수정 후 "예" 선택 - 저장 완료 | 단일 수정 후 UI 확인 | - 수정된 일정에 반복 아이콘 없음 - 다른 반복 일정들은 아이콘 유지 | Normal |
+| F-4-1-3 | 단일 수정 - 나머지 반복 일정 유지 | - 매주 월요일 반복 일정 (총 4개) - 두 번째 일정 선택 - 수정 후 "예" 선택 | 단일 수정 시 나머지 반복 일정 확인 | - 두 번째 일정만 수정됨 - 나머지 3개 일정은 원래 속성 유지 | Normal |
+
+### Story-4-2: 전체 반복 일정 수정
+
+| Flow ID | Flow 이름 | Input | Trigger | Output | 분류 |
+|---------|-----------|-------|---------|--------|------|
+| F-4-2-1 | 전체 수정 - 모든 반복 일정 속성 변경 | - 반복 일정 선택 - 수정 버튼 클릭 - 다이얼로그에서 "아니오" 선택 - 제목, 시간 수정 - 저장 버튼 클릭 | 사용자가 전체 반복 일정 수정을 선택하고 저장 | - 같은 반복 그룹의 모든 일정이 수정됨 - `repeat.type` 유지 - 반복 일정 아이콘 유지 - API 일괄 수정 또는 개별 PUT 호출 | Normal |
+| F-4-2-2 | 전체 수정 - 반복 아이콘 유지 확인 | - 반복 일정 수정 후 "아니오" 선택 - 저장 완료 | 전체 수정 후 UI 확인 | - 모든 반복 일정에 아이콘 유지 - 수정된 속성이 모든 일정에 반영 | Normal |
+| F-4-2-3 | 전체 수정 - 반복 유형 유지 | - 매주 수요일 반복 일정 - 시간만 수정 후 "아니오" 선택 | 전체 수정 시 반복 속성 확인 | - 모든 일정의 `repeat.type = 'weekly'` 유지 - 수정된 시간이 모든 일정에 적용 | Normal |
+
+### Story-4-3: 수정 확인 다이얼로그 표시
+
+| Flow ID | Flow 이름 | Input | Trigger | Output | 분류 |
+|---------|-----------|-------|---------|--------|------|
+| F-4-3-1 | 다이얼로그 표시 | - 반복 일정 선택 - 수정 버튼 클릭 | 반복 일정 수정 시작 | - "해당 일정만 수정하시겠어요?" 다이얼로그 표시 - "예" 버튼 표시 - "아니오" 버튼 표시 | Normal |
+| F-4-3-2 | 다이얼로그에서 "예" 선택 | - 다이얼로그 표시됨 - "예" 버튼 클릭 | 단일 수정 선택 | - 다이얼로그 닫힘 - 일정 수정 폼으로 진행 - 단일 수정 모드 활성화 | Normal |
+| F-4-3-3 | 다이얼로그에서 "아니오" 선택 | - 다이얼로그 표시됨 - "아니오" 버튼 클릭 | 전체 수정 선택 | - 다이얼로그 닫힘 - 일정 수정 폼으로 진행 - 전체 수정 모드 활성화 | Normal |
+| F-4-3-4 | 일반 일정 수정 시 다이얼로그 미표시 | - 일반 일정 (repeat.type = 'none') 선택 - 수정 버튼 클릭 | 일반 일정 수정 시작 | - 다이얼로그 표시되지 않음 - 바로 일정 수정 폼 표시 | Normal |
+
+---
+
+## 4. 요약
+
+### Epic 요약
+- **Epic**: 반복 일정 수정 관리
+- **Story 개수**: 3개
+- **Flow 개수**: 10개 (Normal: 10, Exception: 0)
+
+### Story별 Flow 분포
+| Story | Normal | Exception | 총 Flow |
+|-------|--------|-----------|---------|
+| Story-4-1: 단일 반복 일정 수정 | 3 | 0 | 3 |
+| Story-4-2: 전체 반복 일정 수정 | 3 | 0 | 3 |
+| Story-4-3: 수정 확인 다이얼로그 표시 | 4 | 0 | 4 |
+| **합계** | **10** | **0** | **10** |
+
+### 핵심 기술 요구사항
+1. **다이얼로그 UI**: MUI Dialog를 사용한 선택 확인
+2. **조건부 수정**: 단일/전체 모드에 따른 분기 처리
+3. **반복 속성 관리**: `repeat.type` 변경 및 유지
+4. **반복 그룹 식별**: 같은 반복 일정 그룹 찾기 (제목, 시작시간, 반복 유형 등으로 식별)
+5. **UI 업데이트**: 아이콘 표시/숨김 처리
+
+### 예상 구현 파일
+- **UI**: `src/App.tsx` (다이얼로그 추가)
+- **Hook**: `src/hooks/useEventOperations.ts` (수정 로직 확장)
+- **Utility**: `src/utils/repeatGroupUtils.ts` (반복 그룹 식별, 신규)
+- **Test**: `src/__tests__/integration/feature4-integration.spec.tsx`
+
+---
+
+**작성일**: 2025-10-30
+**Feature**: FEATURE4 - 반복 일정 수정
+**Epic**: 1개 | Stories**: 3개 | **Flows**: 10개
+
diff --git a/.cursor/outputs/2-splited-features/feature5-breakdown.md b/.cursor/outputs/2-splited-features/feature5-breakdown.md
new file mode 100644
index 00000000..eb677eb2
--- /dev/null
+++ b/.cursor/outputs/2-splited-features/feature5-breakdown.md
@@ -0,0 +1,66 @@
+# Feature 5 Breakdown: 반복 일정 삭제
+
+## Epic: 반복 일정 삭제 관리
+
+사용자가 반복 일정을 삭제할 때 단일 일정만 삭제할지, 전체 시리즈를 삭제할지 선택할 수 있도록 합니다.
+
+---
+
+## Story 1: 반복 일정 삭제 모드 선택
+
+반복 일정 삭제 시 다이얼로그를 통해 단일/전체 삭제 모드를 선택할 수 있습니다.
+
+### Flows
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+|---------|------|-------|---------|--------|------|-------|
+| 1-1 | 반복 일정 삭제 클릭 시 확인 다이얼로그가 표시된다 | 반복 일정 (repeat.type !== 'none') | 삭제 버튼 클릭 | "해당 일정만 삭제하시겠어요?" 다이얼로그 표시 | Normal | UI Feedback |
+| 1-2 | 일반 일정 삭제 시 다이얼로그 없이 즉시 삭제된다 | 일반 일정 (repeat.type === 'none') | 삭제 버튼 클릭 | 다이얼로그 없이 즉시 삭제 | Normal | Edge Case |
+
+---
+
+## Story 2: 단일 일정 삭제
+
+반복 일정 중 선택한 하나의 일정만 삭제합니다.
+
+### Flows
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+|---------|------|-------|---------|--------|------|-------|
+| 2-1 | 다이얼로그에서 '예' 선택 시 해당 일정만 삭제된다 | 반복 일정 + 삭제 다이얼로그 | '예' 버튼 클릭 | 선택한 일정만 삭제, 나머지 반복 일정은 유지 | Normal | 단일 삭제 |
+| 2-2 | 단일 삭제 후 삭제 성공 메시지가 표시된다 | 단일 삭제 완료 | 삭제 API 성공 | "일정이 삭제되었습니다" 스낵바 표시 | Normal | UI Feedback |
+| 2-3 | 단일 삭제 후 캘린더에서 해당 일정만 사라진다 | 단일 삭제 완료 | 캘린더 리렌더링 | 삭제된 일정만 캘린더에서 제거됨 | Normal | Visual Verification |
+
+---
+
+## Story 3: 전체 반복 일정 삭제
+
+반복 일정의 모든 시리즈를 삭제합니다.
+
+### Flows
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+|---------|------|-------|---------|--------|------|-------|
+| 3-1 | 다이얼로그에서 '아니오' 선택 시 전체 반복 일정이 삭제된다 | 반복 일정 + 삭제 다이얼로그 | '아니오' 버튼 클릭 | 동일한 반복 그룹의 모든 일정 삭제 | Normal | 전체 삭제 |
+| 3-2 | 전체 삭제 후 삭제 성공 메시지가 표시된다 | 전체 삭제 완료 | 삭제 API 성공 | "일정이 삭제되었습니다" 스낵바 표시 | Normal | UI Feedback |
+| 3-3 | 전체 삭제 후 캘린더에서 모든 반복 일정이 사라진다 | 전체 삭제 완료 | 캘린더 리렌더링 | 동일 그룹의 모든 일정이 캘린더에서 제거됨 | Normal | Visual Verification |
+
+---
+
+## 요약
+
+- **Epic**: 1개 (반복 일정 삭제 관리)
+- **Stories**: 3개 (삭제 모드 선택, 단일 삭제, 전체 삭제)
+- **Flows**: 8개 (UI 2개, 단일 삭제 3개, 전체 삭제 3개)
+
+---
+
+## 기술적 고려사항
+
+1. **반복 그룹 식별**: 제목, 시작 시간, 반복 타입/간격이 동일한 이벤트를 그룹으로 간주 (feature4에서 구현된 `findRepeatGroup` 활용 가능)
+2. **다이얼로그 조건**: `repeat.type !== 'none'`인 경우에만 다이얼로그 표시
+3. **삭제 API**:
+ - 단일 삭제: `DELETE /api/events/{id}`
+ - 전체 삭제: 그룹 내 모든 이벤트 ID에 대해 반복 호출 또는 `DELETE /api/events-list` (구현 방식은 백엔드에 따라 결정)
+4. **UI 업데이트**: 삭제 후 `fetchEvents()`를 호출하여 캘린더 갱신
+
diff --git "a/.cursor/outputs/2-splited-features/\354\235\264\354\240\204 feature2.md" "b/.cursor/outputs/2-splited-features/\354\235\264\354\240\204 feature2.md"
new file mode 100644
index 00000000..4ef684c2
--- /dev/null
+++ "b/.cursor/outputs/2-splited-features/\354\235\264\354\240\204 feature2.md"
@@ -0,0 +1,28 @@
+# FEATURE2 기능 분해 계획
+
+## Epic: 반복 일정 표시
+
+### Story 1: 반복 일정 아이콘 표시
+캘린더 뷰에서 반복 일정을 아이콘으로 구분하여 표시한다.
+
+| Flow ID | Name | Input | Trigger | Output | Type | Notes |
+|---------|------|-------|---------|--------|------|-------|
+| 1-1 | 캘린더가 렌더링되면 반복 일정에 🔁 아이콘이 표시된다 | 반복 일정 목록 | 캘린더 렌더링 | 반복 일정 제목 왼쪽에 🔁 아이콘 표시 | Normal | UI Feedback |
+| 1-2 | 캘린더가 렌더링되면 일반 일정에는 아이콘이 표시되지 않는다 | 일반 일정 목록 | 캘린더 렌더링 | 일반 일정에 아이콘 없음 | Normal | UI Feedback |
+
+## 체크리스트 검증 결과
+
+| 항목 | 검증 결과 | 비고 |
+|------|-----------|------|
+| 1. 사용자 행동이 포함되어 있는가? | ✅ | 시스템 렌더링 행동으로 시작 |
+| 2. 기대 결과가 명확한가? | ✅ | 모든 Flow가 "~표시된다" 형태로 끝남 |
+| 3. 단일 목적 Flow인가? | ✅ | 각 Flow는 하나의 행동과 하나의 결과만 포함 |
+| 4. 예외 조건이 분리되어 있는가? | ✅ | 예외 조건 없음 |
+| 5. 정상/예외 Flow 구분 명확한가? | ✅ | 모든 Flow가 Normal 타입 |
+| 6. Flow 이름이 행동+결과 형태인가? | ✅ | "~되면 ~된다" 형식 준수 |
+| 7. Flow 중복 없는가? | ✅ | 동일한 Input/Trigger/Output 조합 없음 |
+| 8. PRD의 Acceptance Criteria와 매핑되는가? | ✅ | 모든 요구사항이 Flow로 표현됨 |
+| 9. UI 피드백 Flow 존재하는가? | ✅ | 아이콘 표시 관련 Flow 포함 |
+| 10. 내부 로직 언급 없이 사용자 관점인가? | ✅ | 사용자가 확인 가능한 변화만 포함 |
+| 11. Flow 순서가 논리적으로 자연스러운가? | ✅ | 렌더링 순서로 구성 |
+| 12. I/O 정의가 완전한가? | ✅ | 모든 Flow에 Input, Trigger, Output 정의 |
diff --git a/.cursor/outputs/3-integration-test-design/feature1-test-design.md b/.cursor/outputs/3-integration-test-design/feature1-test-design.md
new file mode 100644
index 00000000..fce46929
--- /dev/null
+++ b/.cursor/outputs/3-integration-test-design/feature1-test-design.md
@@ -0,0 +1,98 @@
+# 🧪 테스트 설계서 - FEATURE1: 반복 유형 선택
+
+## 1. 테스트 목적
+
+반복 일정 생성 기능이 올바르게 동작하는지 검증한다. 사용자가 반복 설정 체크박스를 통해 반복 옵션을 활성화하고, 드롭다운에서 반복 유형(매일, 매주, 매월, 매년)을 선택하여 저장 시 반복 일정이 정상적으로 생성되는지 확인한다. 특히 매월 반복 시 31일과 같이 특정 월에 존재하지 않는 날짜, 윤년 29일의 매년 반복 등 예외 상황에서도 올바르게 동작하는지 검증한다.
+
+## 2. 테스트 범위
+
+### 포함
+
+- 반복 설정 체크박스 활성화/비활성화 동작
+- 반복 드롭다운 표시/숨김 동작
+- 반복 유형 선택 기능 (매일, 매주, 매월, 매년)
+- 반복 일정 생성 기능
+- 매월 반복 시 해당 날짜가 없는 월의 예외 처리
+- 윤년 29일 매년 반복 시 평년의 예외 처리
+
+### 제외
+
+- 반복 종료 조건 설정 기능 (FEATURE3에서 다룸)
+- 반복 일정 표시 기능 (FEATURE2에서 다룸)
+- 반복 일정 수정 기능 (FEATURE4에서 다룸)
+- 반복 일정 삭제 기능 (FEATURE5에서 다룸)
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | ------------------------------------------------------- |
+| 단위 테스트 | 반복 설정 관련 유틸 함수 검증 |
+| 통합 테스트 | 반복 설정 UI 컴포넌트와 일정 생성 로직 간 상호작용 검증 |
+
+## 4. 테스트 시나리오
+
+### Story 1: 반복 설정 활성화
+
+| 시나리오 ID | 설명 | 입력 | 기대 결과 | 검증 포인트 | 테스트 유형 |
+| ----------- | --------------------------------------------------- | -------------------------------- | ----------------------------- | -------------------------------------- | ----------- |
+| TC-1-1 | 반복 설정 체크박스 클릭 시 반복 드롭다운이 표시된다 | 반복 설정 체크박스 클릭 | 반복 드롭다운이 화면에 표시됨 | 드롭다운 요소가 DOM에 존재하고 visible | 통합 |
+| TC-1-2 | 반복 설정 체크박스 해제 시 반복 드롭다운이 숨겨진다 | 반복 설정 체크박스 해제 | 반복 드롭다운이 화면에서 숨김 | 드롭다운 요소가 hidden 또는 제거됨 | 통합 |
+| TC-1-3 | 반복 설정 토글 동작이 정상적으로 작동한다 | 체크박스 클릭 → 해제 → 다시 클릭 | 드롭다운 표시/숨김 반복 | 각 클릭마다 표시 상태가 올바르게 변경 | 통합 |
+
+### Story 2: 반복 유형 선택
+
+| 시나리오 ID | 설명 | 입력 | 기대 결과 | 검증 포인트 | 테스트 유형 |
+| ----------- | ------------------------------------------- | ------------------------ | --------------------------- | ---------------------------------- | ----------- |
+| TC-2-1 | 매일 반복 선택 시 매일 반복 설정이 적용된다 | 드롭다운에서 "매일" 선택 | 반복 유형이 "매일"로 설정됨 | state 또는 form value = "daily" | 통합 |
+| TC-2-2 | 매주 반복 선택 시 매주 반복 설정이 적용된다 | 드롭다운에서 "매주" 선택 | 반복 유형이 "매주"로 설정됨 | state 또는 form value = "weekly" | 통합 |
+| TC-2-3 | 매월 반복 선택 시 매월 반복 설정이 적용된다 | 드롭다운에서 "매월" 선택 | 반복 유형이 "매월"로 설정됨 | state 또는 form value = "monthly" | 통합 |
+| TC-2-4 | 매년 반복 선택 시 매년 반복 설정이 적용된다 | 드롭다운에서 "매년" 선택 | 반복 유형이 "매년"로 설정됨 | state 또는 form value = "yearly" | 통합 |
+| TC-2-5 | 드롭다운에 모든 반복 옵션이 표시된다 | 드롭다운 열기 | 4개 옵션 모두 표시됨 | 매일, 매주, 매월, 매년 옵션이 존재 | 통합 |
+
+### Story 3: 반복 일정 생성
+
+| 시나리오 ID | 설명 | 입력 | 기대 결과 | 검증 포인트 | 테스트 유형 |
+| ----------- | ------------------------------------------------ | ------------------------------------ | ---------------------------------- | ----------------------------------------- | ----------- |
+| TC-3-1 | 매일 반복 일정이 정상적으로 생성된다 | 반복: 매일, 저장 버튼 클릭 | 매일 반복 일정이 생성됨 | 생성된 일정 배열에 매일 날짜가 포함됨 | 통합 |
+| TC-3-2 | 매주 반복 일정이 정상적으로 생성된다 | 반복: 매주, 저장 버튼 클릭 | 매주 반복 일정이 생성됨 | 생성된 일정 배열에 매주 날짜가 포함됨 | 통합 |
+| TC-3-3 | 매월 반복 일정이 정상적으로 생성된다 | 반복: 매월, 시작일: 15일, 저장 | 매월 15일에 반복 일정이 생성됨 | 생성된 일정 배열의 모든 날짜가 15일임 | 통합 |
+| TC-3-4 | 매년 반복 일정이 정상적으로 생성된다 | 반복: 매년, 시작일: 3월 15일, 저장 | 매년 3월 15일에 반복 일정이 생성됨 | 생성된 일정 배열의 모든 날짜가 3월 15일임 | 통합 |
+| TC-3-5 | 31일 매월 반복 시 2월에는 생성되지 않는다 (예외) | 반복: 매월, 시작일: 1월 31일, 저장 | 2월 제외, 31일 있는 월에만 생성됨 | 생성된 일정 배열에 2월 날짜가 없음 | 통합 |
+| TC-3-6 | 31일 매월 반복 시 30일까지 있는 월에는 생성 안됨 | 반복: 매월, 시작일: 1월 31일, 저장 | 4월, 6월, 9월, 11월에 생성 안됨 | 생성된 일정 배열에 해당 월 날짜가 없음 | 통합 |
+| TC-3-7 | 윤년 29일 매년 반복 시 평년에는 생성 안됨 (예외) | 반복: 매년, 시작일: 2024-02-29, 저장 | 2025, 2026, 2027년에 생성 안됨 | 생성된 일정 배열에 평년 2월 29일이 없음 | 통합 |
+| TC-3-8 | 윤년 29일 매년 반복 시 다음 윤년에는 생성됨 | 반복: 매년, 시작일: 2024-02-29, 저장 | 2028년 2월 29일에 생성됨 | 생성된 일정 배열에 2028-02-29가 포함됨 | 통합 |
+
+## 5. 단위 테스트 시나리오
+
+| 시나리오 ID | 함수/모듈 | 입력 | 기대 출력 | 검증 포인트 | 테스트 유형 |
+| ----------- | ---------------------------- | ------------------ | --------- | ------------------------ | ----------- |
+| UT-1 | isLeapYear(year) | 2024 | true | 윤년 판별 로직 | 단위 |
+| UT-2 | isLeapYear(year) | 2025 | false | 평년 판별 로직 | 단위 |
+| UT-3 | getLastDayOfMonth(year, mon) | 2024, 2 | 29 | 윤년 2월 마지막 날 | 단위 |
+| UT-4 | getLastDayOfMonth(year, mon) | 2025, 2 | 28 | 평년 2월 마지막 날 | 단위 |
+| UT-5 | isValidDateInMonth(y, m, d) | 2025, 2, 29 | false | 존재하지 않는 날짜 검증 | 단위 |
+| UT-6 | isValidDateInMonth(y, m, d) | 2024, 2, 29 | true | 존재하는 날짜 검증 | 단위 |
+| UT-7 | generateRecurringEvents() | type: 'daily' | 매일 배열 | 매일 반복 일정 생성 로직 | 단위 |
+| UT-8 | generateRecurringEvents() | type: 'monthly', d | 31일 배열 | 31일 존재하는 월만 포함 | 단위 |
+
+## 6. 테스트 데이터
+
+- 테스트용 시작 날짜: 2024-01-01
+- 윤년 테스트: 2024-02-29
+- 31일 테스트: 1월 31일, 3월 31일, 5월 31일, 7월 31일, 8월 31일, 10월 31일, 12월 31일
+- 30일까지 있는 월: 4월, 6월, 9월, 11월
+- 28/29일까지 있는 월: 2월
+
+## 7. 비고
+
+### 확인 필요 사항
+
+1. 반복 설정 체크했으나 반복 유형 미선택 시 동작: 에러 처리 또는 기본값 적용 방식 확인 필요
+2. 반복 일정 생성 시 최대 개수 제한 확인 필요
+
+### 테스트 우선순위
+
+- P0: TC-3-1 ~ TC-3-4, TC-3-5, TC-3-7 (핵심 기능)
+- P1: TC-1-1, TC-1-2, TC-2-1 ~ TC-2-4 (UI 상호작용)
+- P2: TC-3-6, TC-3-8 (추가 예외)
+- P3: TC-1-3, TC-2-5 (엣지 케이스)
diff --git a/.cursor/outputs/3-integration-test-design/feature2-test-design.md b/.cursor/outputs/3-integration-test-design/feature2-test-design.md
new file mode 100644
index 00000000..96bcc9e4
--- /dev/null
+++ b/.cursor/outputs/3-integration-test-design/feature2-test-design.md
@@ -0,0 +1,118 @@
+# 🧪 FEATURE2 통합 테스트 설계서
+
+## 1. 테스트 목적
+
+반복 일정이 캘린더 뷰(월간/주간)와 일정 목록에서 아이콘을 통해 시각적으로 구분되며, 모든 뷰에서 일관된 아이콘이 표시되는지 검증한다.
+
+---
+
+## 2. 테스트 범위
+
+### 포함
+
+- 월간 뷰에서 반복 일정 아이콘 표시
+- 주간 뷰에서 반복 일정 아이콘 표시
+- 일정 목록에서 반복 일정 아이콘 표시
+- 일반 일정은 아이콘 미표시 검증
+- 모든 반복 유형(daily, weekly, monthly, yearly)에 동일 아이콘 표시
+- 아이콘 위치 일관성 검증
+
+### 제외
+
+- 아이콘 디자인/색상/크기의 세밀한 스타일 검증
+- 반복 일정 생성 로직 (Feature 1에서 담당)
+- 반복 일정 수정/삭제 기능
+
+---
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | -------------------------------------- |
+| 통합 테스트 | 캘린더 뷰와 일정 목록 컴포넌트 UI 검증 |
+
+---
+
+## 4. 테스트 시나리오
+
+### Story 1: 캘린더 뷰에서 반복 일정 아이콘 표시
+
+| 시나리오 ID | 설명 | 입력 | 기대 결과 | 검증 방법 | 테스트 유형 |
+| ----------- | ---------------------------------------------- | ----------------------------------------- | ------------------------------- | -------------------------------------- | ----------- |
+| TC-2-1-1 | 월간 뷰에서 반복 일정이 아이콘과 함께 표시된다 | 반복 일정 데이터 (repeat.type !== 'none') | 일정 제목 앞에 반복 아이콘 표시 | 월간 뷰에서 반복 아이콘 요소 존재 확인 | 통합 |
+| TC-2-1-2 | 주간 뷰에서 반복 일정이 아이콘과 함께 표시된다 | 반복 일정 데이터 (repeat.type !== 'none') | 일정 제목 앞에 반복 아이콘 표시 | 주간 뷰에서 반복 아이콘 요소 존재 확인 | 통합 |
+| TC-2-1-3 | 일반 일정은 아이콘 없이 제목만 표시된다 | 일반 일정 데이터 (repeat.type === 'none') | 제목만 표시, 아이콘 없음 | 일반 일정에 반복 아이콘 요소 부재 확인 | 통합 |
+
+### Story 2: 일정 목록에서 반복 일정 아이콘 표시
+
+| 시나리오 ID | 설명 | 입력 | 기대 결과 | 검증 방법 | 테스트 유형 |
+| ----------- | ------------------------------------------------ | ----------------------------------------- | ------------------------------- | ---------------------------------------- | ----------- |
+| TC-2-2-1 | 일정 목록에서 반복 일정이 아이콘과 함께 표시된다 | 반복 일정 데이터 (repeat.type !== 'none') | 일정 제목 앞에 반복 아이콘 표시 | 일정 목록에서 반복 아이콘 요소 존재 확인 | 통합 |
+| TC-2-2-2 | 일정 목록에서 일반 일정은 아이콘 없이 표시된다 | 일반 일정 데이터 (repeat.type === 'none') | 제목만 표시, 아이콘 없음 | 일정 목록에서 반복 아이콘 요소 부재 확인 | 통합 |
+
+### Story 3: 반복 일정 아이콘 일관성 유지
+
+| 시나리오 ID | 설명 | 입력 | 기대 결과 | 검증 방법 | 테스트 유형 |
+| ----------- | ---------------------------------------------- | ------------------------------------------------------ | ----------------------------------- | ------------------------------------------------- | ----------- |
+| TC-2-3-1 | 모든 반복 유형이 동일한 아이콘으로 표시된다 | 다양한 반복 유형 일정 (daily, weekly, monthly, yearly) | 모든 반복 일정에 동일한 아이콘 표시 | 모든 반복 아이콘의 타입/클래스가 동일한지 확인 | 통합 |
+| TC-2-3-2 | 아이콘 위치가 일정 제목 앞에 일관되게 표시된다 | 반복 일정 데이터 | 아이콘이 항상 제목 왼쪽에 위치 | 아이콘과 제목의 DOM 순서 및 위치 구조 일관성 확인 | 통합 |
+
+---
+
+## 5. Mock 데이터 구조
+
+```typescript
+const mockRepeatingEvent = {
+ id: '1',
+ title: '매주 회의',
+ date: '2024-01-15',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+};
+
+const mockNormalEvent = {
+ id: '2',
+ title: '일반 회의',
+ date: '2024-01-16',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'none', interval: 1 },
+ notificationTime: 10,
+};
+```
+
+---
+
+## 6. 검증 포인트
+
+### UI 검증
+
+- 반복 일정에 아이콘 요소 존재 여부 (`aria-label="반복 일정"` 또는 `data-testid="repeat-icon"`)
+- 일반 일정에 아이콘 요소 부재 확인 (`queryBy`로 null 검증)
+- 아이콘과 제목의 렌더링 순서 (아이콘이 제목 앞에 위치)
+
+### 접근성
+
+- 아이콘에 적절한 aria-label 속성 존재
+- 스크린 리더 사용자를 위한 의미 전달
+
+### 일관성
+
+- 모든 뷰에서 동일한 아이콘 컴포넌트 사용
+- 모든 반복 유형에 동일한 아이콘 표시
+
+---
+
+## 7. 비고
+
+- 반복 일정 생성 기능은 Feature 1에서 이미 검증되었으므로, 이 테스트에서는 시각적 표시에만 집중한다.
+- 아이콘 존재 여부와 위치만 검증하며, 세부 스타일(색상, 크기)은 시각적 회귀 테스트 영역이므로 제외한다.
+- 테스트는 사용자가 실제로 확인할 수 있는 DOM 구조와 접근성 속성을 기반으로 작성한다.
diff --git a/.cursor/outputs/3-integration-test-design/feature3-test-design.md b/.cursor/outputs/3-integration-test-design/feature3-test-design.md
new file mode 100644
index 00000000..83076a3a
--- /dev/null
+++ b/.cursor/outputs/3-integration-test-design/feature3-test-design.md
@@ -0,0 +1,186 @@
+# 🧪 테스트 설계서: Feature 3 - 반복 일정 종료 조건
+
+## 1. 테스트 목적
+
+반복 일정의 종료 조건 설정 기능이 올바르게 동작하는지 검증합니다.
+
+- 사용자가 종료 날짜를 설정하면 해당 날짜까지만 반복 일정이 생성됨
+- 종료 날짜 미입력 시 기본값(2025-12-31)이 적용됨
+- 잘못된 종료 날짜 입력 시 적절한 검증이 수행됨
+- 종료 날짜 수정 기능이 정상 작동함
+
+## 2. 테스트 범위
+
+### 포함
+
+- 반복 유형 선택 시 종료 날짜 입력 필드 표시
+- 종료 날짜 입력 및 저장 기능
+- 종료 날짜 기본값(2025-12-31) 적용
+- 종료 날짜 검증 (시작 날짜보다 이전, 최대값 초과)
+- 종료 날짜에 따른 반복 일정 생성 제한
+- 반복 일정 수정 시 종료 날짜 표시 및 수정
+- UI에서 종료 날짜 정보 표시
+
+### 제외
+
+- 반복 일정의 기본 생성 로직 (Feature 1에서 검증)
+- 반복 일정의 시각적 구분 (Feature 2에서 검증)
+- 백엔드 API 실제 통신 (MSW로 모킹)
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | --------------------------------------- |
+| 통합 테스트 | UI 컴포넌트와 비즈니스 로직 간 상호작용 |
+| 단위 테스트 | 날짜 검증, 반복 생성 로직 등 순수 함수 |
+
+## 4. 테스트 시나리오
+
+### Story 1: 반복 종료 조건 설정
+
+| TC ID | 설명 | 입력 | 사용자 행동 | 기대 결과 | 테스트 유형 |
+| -------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ----------- |
+| TC-3-1-1 | 반복 체크 시 종료 날짜 입력 필드가 표시된다 | 반복 체크박스: 체크 | 1. 일정 추가 폼 열기 2. "반복 일정" 체크박스 체크 | 종료 날짜 입력 필드가 화면에 표시됨 | 통합 |
+| TC-3-1-2 | 반복 유형 "반복 안함" 선택 시 종료 날짜 필드가 숨겨진다 | 반복 유형: "반복 안함" 선택 | 1. 일정 추가 폼 열기 2. 반복 유형 드롭다운에서 "반복 안함" 선택 | 종료 날짜 입력 필드가 화면에서 숨겨짐 | 통합 |
+| TC-3-1-3 | 종료 날짜를 입력하고 저장하면 해당 날짜까지만 반복 일정이 생성된다 | - 제목: "매일 회의" - 시작 날짜: 2025-10-01 - 반복: 매일 - 종료: 2025-10-05 | 1. 일정 추가 폼에 입력 2. 저장 버튼 클릭 | - API 호출: POST /api/events-list - 생성된 일정: 5개 (10/1~10/5) - 각 일정의 날짜가 2025-10-01부터 2025-10-05까지 | 통합 |
+| TC-3-1-4 | 종료 날짜 미입력 시 기본값(2025-12-31)까지 생성된다 | - 제목: "매주 회의" - 시작 날짜: 2025-10-01 - 반복: 매주 - 종료: (비어있음) | 1. 일정 추가 폼에 입력 (종료 날짜 제외) 2. 저장 버튼 클릭 | - API 호출: POST /api/events-list - 생성된 일정의 마지막 날짜가 2025-12-31 이전 - 2026-01-01 이후 일정은 생성되지 않음 | 통합 |
+
+### Story 2: 반복 종료 조건 검증
+
+| TC ID | 설명 | 입력 | 사용자 행동 | 기대 결과 | 테스트 유형 |
+| -------- | ----------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ----------- |
+| TC-3-2-1 | 종료 날짜가 시작 날짜보다 이전이면 에러 메시지가 표시된다 | - 시작 날짜: 2025-10-15 - 반복: 매일 - 종료: 2025-10-10 | 1. 일정 추가 폼에 입력 2. 저장 버튼 클릭 | - 일정이 저장되지 않음 - 에러 메시지 표시: "종료 날짜는 시작 날짜 이후여야 합니다" - API 호출 없음 | 통합 |
+| TC-3-2-2 | 반복 일정이 종료 날짜 다음날부터는 생성되지 않는다 | - 시작: 2025-10-01 - 반복: 매일 - 종료: 2025-10-10 | 1. 일정 추가 폼에 입력 2. 저장 버튼 클릭 3. 생성된 일정 확인 | - 생성된 일정: 정확히 10개 - 마지막 일정 날짜: 2025-10-10 - 2025-10-11 일정은 생성되지 않음 | 통합 |
+| TC-3-2-3 | 종료 날짜가 2025-12-31을 초과하면 2025-12-31까지만 생성된다 | - 시작: 2025-12-01 - 반복: 매일 - 종료: 2026-01-31 | 1. 일정 추가 폼에 입력 2. 저장 버튼 클릭 3. 생성된 일정 확인 | - 마지막 일정 날짜: 2025-12-31 - 2026-01-01 이후 일정은 생성되지 않음 - 총 31개 일정 생성 (12/1~12/31) | 통합 |
+
+### Story 3: 종료 날짜 수정 및 표시
+
+| TC ID | 설명 | 입력 | 사용자 행동 | 기대 결과 | 테스트 유형 |
+| -------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | ----------- |
+| TC-3-3-1 | 반복 일정 수정 시 기존 종료 날짜가 입력 필드에 표시된다 | 기존 일정: - 반복: 매주 - 종료: 2025-10-31 | 1. 반복 일정 클릭 2. 수정 버튼 클릭 | - 일정 수정 폼이 열림 - 종료 날짜 필드에 "2025-10-31" 표시 | 통합 |
+| TC-3-3-2 | 종료 날짜를 수정하면 새로운 종료 날짜가 반영된다 | - 기존 종료: 2025-10-31 - 수정 종료: 2025-11-30 | 1. 반복 일정 수정 폼 열기 2. 종료 날짜를 2025-11-30으로 변경 3. 저장 버튼 클릭 | - API 호출: PUT /api/events/{id} - 수정된 일정의 종료 날짜가 2025-11-30으로 업데이트됨 | 통합 |
+| TC-3-3-3 | 일정 목록에서 반복 일정의 종료 날짜 정보가 표시된다 | 반복 일정: - 제목: "매주 회의" - 종료: 2025-12-31 | 1. 캘린더 또는 일정 목록 확인 | - 일정 제목 옆 또는 아래에 "2025-12-31까지" 텍스트 표시 - 또는 "~까지 반복" 형태의 안내 문구 표시 | 통합 |
+
+## 5. 테스트 데이터
+
+### Mock Events
+
+```typescript
+// 종료 날짜가 있는 반복 일정
+const repeatingEventWithEnd: Event = {
+ id: 'repeat-with-end-1',
+ title: '매주 회의',
+ date: '2025-10-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '종료 날짜가 있는 반복 일정',
+ location: '회의실',
+ category: '업무',
+ repeat: {
+ type: 'weekly',
+ interval: 1,
+ endDate: '2025-10-31', // 종료 날짜
+ },
+ notificationTime: 10,
+};
+
+// 종료 날짜가 없는 반복 일정 (기본값 적용)
+const repeatingEventNoEnd: Event = {
+ id: 'repeat-no-end-1',
+ title: '매일 운동',
+ date: '2025-10-01',
+ startTime: '07:00',
+ endTime: '08:00',
+ description: '종료 날짜 없음',
+ location: '헬스장',
+ category: '개인',
+ repeat: {
+ type: 'daily',
+ interval: 1,
+ endDate: undefined, // 기본값 2025-12-31 적용
+ },
+ notificationTime: 10,
+};
+```
+
+## 6. 검증 기준 (Assertion Points)
+
+### UI 검증
+
+- [ ] 반복 유형 선택 시 종료 날짜 필드 표시/숨김
+- [ ] 종료 날짜 입력 필드가 date 타입 input으로 표시
+- [ ] 에러 메시지가 사용자에게 명확히 표시
+- [ ] 일정 수정 시 기존 종료 날짜가 pre-filled
+- [ ] 일정 목록에서 종료 날짜 정보 표시
+
+### 데이터 검증
+
+- [ ] 종료 날짜가 없으면 2025-12-31로 기본 설정
+- [ ] 종료 날짜가 시작 날짜보다 이전이면 저장 차단
+- [ ] 종료 날짜가 2025-12-31을 초과하면 2025-12-31로 제한
+- [ ] 생성된 반복 일정의 개수가 정확함
+- [ ] 종료 날짜 다음날 일정은 생성되지 않음
+
+### API 호출 검증
+
+- [ ] POST /api/events-list에 올바른 이벤트 배열 전달
+- [ ] PUT /api/events/{id}에 수정된 종료 날짜 포함
+- [ ] 에러 발생 시 API 호출 없음
+
+## 7. 엣지 케이스 및 경계값
+
+| 케이스 | 입력값 | 기대 동작 |
+| -------------------------- | ---------------------------------------- | ---------------------------- |
+| 종료 날짜 = 시작 날짜 | 시작: 2025-10-15, 종료: 2025-10-15 | 1개 일정만 생성 |
+| 종료 날짜 = 시작 날짜 - 1 | 시작: 2025-10-15, 종료: 2025-10-14 | 에러 발생, 저장 차단 |
+| 종료 날짜 = 2025-12-31 | 반복: 매일, 종료: 2025-12-31 | 2025-12-31까지 생성 |
+| 종료 날짜 = 2025-12-31 + 1 | 반복: 매일, 종료: 2026-01-01 | 2025-12-31까지만 생성 (제한) |
+| 종료 날짜 = null/undefined | 반복: 매주 | 2025-12-31까지 생성 (기본값) |
+| 윤년 + 종료 날짜 | 시작: 2024-02-28, 종료: 2024-02-29, 매일 | 2개 일정 생성 (28, 29일) |
+
+## 8. 비고
+
+### 확인 필요 사항
+
+- ✅ 종료 날짜 기본값: 2025-12-31 (PRD 명시)
+- ✅ 종료 날짜 최대값: 2025-12-31 (PRD 명시)
+- ⚠️ UI 표시 형식: "2025-12-31까지" vs "~까지 반복" (사용자 확인 필요)
+- ⚠️ 종료 날짜 수정 시 기존 생성된 일정 처리 방법 (삭제 후 재생성? 추가 생성?)
+
+### 구현 시 고려사항
+
+- 종료 날짜는 `repeat` 객체의 `endDate` 속성으로 관리
+- 날짜 비교 시 타임존 고려 필요
+- 종료 날짜 검증은 클라이언트 측에서 수행 (빠른 피드백)
+- 반복 일정 생성 로직에서 `endDate` 또는 `2025-12-31` 중 작은 값 사용
+
+### 테스트 코드 구조 제안
+
+```typescript
+describe('FEATURE3: 반복 일정 종료 조건', () => {
+ describe('Story 1: 반복 종료 조건 설정', () => {
+ it('TC-3-1-1: 반복 유형 선택 시 종료 날짜 입력 필드가 표시된다', ...);
+ it('TC-3-1-2: 반복 유형 "반복 안함" 선택 시 종료 날짜 필드가 숨겨진다', ...);
+ it('TC-3-1-3: 종료 날짜를 입력하고 저장하면 해당 날짜까지만 반복 일정이 생성된다', ...);
+ it('TC-3-1-4: 종료 날짜 미입력 시 기본값(2025-12-31)까지 생성된다', ...);
+ });
+
+ describe('Story 2: 반복 종료 조건 검증', () => {
+ it('TC-3-2-1: 종료 날짜가 시작 날짜보다 이전이면 에러 메시지가 표시된다', ...);
+ it('TC-3-2-2: 반복 일정이 종료 날짜 다음날부터는 생성되지 않는다', ...);
+ it('TC-3-2-3: 종료 날짜가 2025-12-31을 초과하면 2025-12-31까지만 생성된다', ...);
+ });
+
+ describe('Story 3: 종료 날짜 수정 및 표시', () => {
+ it('TC-3-3-1: 반복 일정 수정 시 기존 종료 날짜가 입력 필드에 표시된다', ...);
+ it('TC-3-3-2: 종료 날짜를 수정하면 새로운 종료 날짜가 반영된다', ...);
+ it('TC-3-3-3: 일정 목록에서 반복 일정의 종료 날짜 정보가 표시된다', ...);
+ });
+});
+```
+
+---
+
+**테스트 설계 완료일**: 2025-10-30
+**총 테스트 케이스**: 10개 (Story 1: 4개, Story 2: 3개, Story 3: 3개)
+**테스트 유형**: 통합 테스트 10개
diff --git a/.cursor/outputs/3-integration-test-design/feature4-test-design.md b/.cursor/outputs/3-integration-test-design/feature4-test-design.md
new file mode 100644
index 00000000..0de3bd1d
--- /dev/null
+++ b/.cursor/outputs/3-integration-test-design/feature4-test-design.md
@@ -0,0 +1,200 @@
+# 🧪 테스트 설계서: Feature 4 - 반복 일정 수정
+
+## 1. 테스트 목적
+
+반복 일정 수정 시 단일 수정과 전체 수정이 올바르게 동작하는지 검증합니다.
+
+- 다이얼로그가 적절히 표시되고 사용자 선택을 처리하는지 확인
+- 단일 수정 시 선택한 일정만 수정되고 반복 속성이 제거되는지 확인
+- 전체 수정 시 모든 반복 일정이 수정되고 반복 속성이 유지되는지 확인
+- 일반 일정 수정 시 다이얼로그가 표시되지 않는지 확인
+
+## 2. 테스트 범위
+
+### 포함
+
+- 반복 일정 수정 시 다이얼로그 표시 및 선택 처리
+- 단일 수정 모드: 선택한 일정만 수정, 반복 속성 제거
+- 전체 수정 모드: 모든 반복 일정 수정, 반복 속성 유지
+- 반복 아이콘 표시/숨김 변화
+- 일반 일정 수정 시 다이얼로그 미표시
+
+### 제외
+
+- 반복 일정 생성 로직 (Feature 1에서 검증)
+- 반복 일정 시각적 구분 (Feature 2에서 검증)
+- 반복 일정 종료 날짜 (Feature 3에서 검증)
+- 백엔드 API 실제 통신 (MSW로 모킹)
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | ------------------------------------------- |
+| 통합 테스트 | UI 다이얼로그와 비즈니스 로직 간 상호작용 |
+| 단위 테스트 | 반복 그룹 식별, 일괄 수정 로직 등 순수 함수 |
+
+## 4. 테스트 시나리오
+
+### Story 1: 단일 반복 일정 수정
+
+| TC ID | 설명 | 입력 | 사용자 행동 | 기대 결과 | 테스트 유형 |
+| -------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------- |
+| TC-4-1-1 | 단일 수정 선택 시 해당 일정만 수정되고 반복 속성이 제거된다 | - 반복 일정 (매주 월요일) 존재 - ID: "repeat-1" - 제목: "팀 미팅" | 1. 반복 일정 클릭 2. 수정 버튼 클릭 3. 다이얼로그에서 "예" 버튼 클릭 4. 제목을 "개인 미팅"으로 변경 5. 저장 버튼 클릭 | - API PUT /api/events/repeat-1 호출 - body.repeat.type = 'none' - body.title = "개인 미팅" - 다른 반복 일정은 변경 없음 | 통합 |
+| TC-4-1-2 | 단일 수정 후 해당 일정의 반복 아이콘이 사라진다 | - 단일 수정 완료된 일정 | 1. 일정 목록 확인 | - 수정된 일정에 반복 아이콘 없음 - queryByLabelText('반복 일정') 결과 null | 통합 |
+| TC-4-1-3 | 단일 수정 시 나머지 반복 일정은 유지된다 | - 매주 수요일 반복 일정 4개 - 두 번째 일정 ID: "repeat-2" | 1. 두 번째 일정 클릭 2. 수정 버튼 클릭 3. "예" 선택 4. 시간 변경 5. 저장 | - 두 번째 일정만 PUT 호출 - 나머지 3개 일정은 API 호출 없음 - 나머지 3개는 반복 아이콘 유지 | 통합 |
+
+### Story 2: 전체 반복 일정 수정
+
+| TC ID | 설명 | 입력 | 사용자 행동 | 기대 결과 | 테스트 유형 |
+| -------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------- |
+| TC-4-2-1 | 전체 수정 선택 시 모든 반복 일정이 수정되고 반복 속성이 유지된다 | - 매주 화요일 반복 일정 3개 - IDs: ["repeat-a", "repeat-b", "repeat-c"] - 제목: "운동" | 1. 첫 번째 일정 클릭 2. 수정 버튼 클릭 3. 다이얼로그에서 "아니오" 버튼 클릭 4. 제목을 "헬스"로 변경 5. 저장 버튼 클릭 | - API PUT 호출 3번 (각 ID마다) - 모든 body.repeat.type = 'weekly' 유지 - 모든 body.title = "헬스" | 통합 |
+| TC-4-2-2 | 전체 수정 후 모든 일정의 반복 아이콘이 유지된다 | - 전체 수정 완료된 반복 일정들 | 1. 일정 목록 확인 | - 모든 일정에 반복 아이콘 표시 - getAllByLabelText('반복 일정') 개수 = 반복 일정 개수 | 통합 |
+| TC-4-2-3 | 전체 수정 시 반복 유형이 유지된다 | - 매월 1일 반복 일정 2개 - repeat.type = 'monthly' | 1. 첫 번째 일정 수정 2. "아니오" 선택 3. 시간만 변경 4. 저장 | - 모든 API 호출 body.repeat.type = 'monthly' - 모든 일정의 시간이 변경됨 | 통합 |
+
+### Story 3: 수정 확인 다이얼로그 표시
+
+| TC ID | 설명 | 입력 | 사용자 행동 | 기대 결과 | 테스트 유형 |
+| -------- | --------------------------------------------------------- | --------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------- |
+| TC-4-3-1 | 반복 일정 수정 시 다이얼로그가 표시된다 | - 반복 일정 (매일) 존재 | 1. 반복 일정 클릭 2. 수정 버튼 클릭 | - 다이얼로그 표시됨 - "해당 일정만 수정하시겠어요?" 텍스트 존재 - "예" 버튼 존재 - "아니오" 버튼 존재 | 통합 |
+| TC-4-3-2 | 다이얼로그에서 "예" 선택 시 단일 수정 모드로 진행된다 | - 다이얼로그 표시 상태 | 1. "예" 버튼 클릭 | - 다이얼로그 닫힘 - 일정 수정 폼 표시 - (이후 저장 시 단일 수정 동작 - TC-4-1-1에서 검증) | 통합 |
+| TC-4-3-3 | 다이얼로그에서 "아니오" 선택 시 전체 수정 모드로 진행된다 | - 다이얼로그 표시 상태 | 1. "아니오" 버튼 클릭 | - 다이얼로그 닫힘 - 일정 수정 폼 표시 - (이후 저장 시 전체 수정 동작 - TC-4-2-1에서 검증) | 통합 |
+| TC-4-3-4 | 일반 일정 수정 시 다이얼로그가 표시되지 않는다 | - 일반 일정 (repeat.type = 'none') 존재 | 1. 일반 일정 클릭 2. 수정 버튼 클릭 | - 다이얼로그 표시되지 않음 - 바로 일정 수정 폼 표시 - queryByText("해당 일정만 수정하시겠어요?") = null | 통합 |
+
+## 5. 테스트 데이터
+
+### Mock Repeating Events (Group)
+
+```typescript
+const mockRepeatingEvents: Event[] = [
+ {
+ id: 'repeat-mon-1',
+ title: '팀 미팅',
+ date: '2025-10-06', // 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'repeat-mon-2',
+ title: '팀 미팅',
+ date: '2025-10-13', // 다음 주 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'repeat-mon-3',
+ title: '팀 미팅',
+ date: '2025-10-20', // 다다음 주 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+];
+
+const mockNormalEvent: Event = {
+ id: 'normal-1',
+ title: '일반 회의',
+ date: '2025-10-07',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '일반 일정',
+ location: '사무실',
+ category: '업무',
+ repeat: { type: 'none', interval: 1 },
+ notificationTime: 10,
+};
+```
+
+## 6. 검증 기준 (Assertion Points)
+
+### UI 검증
+
+- [ ] 반복 일정 수정 시 다이얼로그 표시
+- [ ] 다이얼로그에 "해당 일정만 수정하시겠어요?" 텍스트 존재
+- [ ] "예", "아니오" 버튼 존재
+- [ ] 일반 일정 수정 시 다이얼로그 미표시
+- [ ] 단일 수정 후 반복 아이콘 사라짐
+- [ ] 전체 수정 후 반복 아이콘 유지
+
+### 데이터 검증
+
+- [ ] 단일 수정 시 `repeat.type = 'none'`으로 변경
+- [ ] 전체 수정 시 `repeat.type` 유지
+- [ ] 단일 수정 시 1개 API 호출
+- [ ] 전체 수정 시 N개 API 호출 (반복 일정 개수만큼)
+
+### API 호출 검증
+
+- [ ] 단일 수정: PUT /api/events/{id} 1회
+- [ ] 전체 수정: PUT /api/events/{id} N회 (각 반복 일정마다)
+- [ ] 각 요청 body에 수정된 속성 포함
+- [ ] 단일 수정 시 repeat.type = 'none' 포함
+
+## 7. 엣지 케이스 및 경계값
+
+| 케이스 | 입력값 | 기대 동작 |
+| ---------------------------- | --------------------- | ----------------------------------- |
+| 반복 일정 1개만 존재 | 단일 반복 일정 | 단일/전체 수정 동일 결과 |
+| 매우 많은 반복 일정 (100개) | 전체 수정 선택 | 100번 API 호출 (또는 일괄 수정 API) |
+| 다이얼로그에서 취소 (ESC) | ESC 키 입력 | 다이얼로그 닫힘, 수정 취소 |
+| 제목이 같지만 다른 반복 일정 | 다른 그룹의 같은 제목 | 해당 그룹만 수정 |
+
+## 8. 비고
+
+### 확인 필요 사항
+
+- ⚠️ **반복 그룹 식별 방법**: 제목 + 시작시간 + 반복유형 + 간격으로 식별? 또는 별도 groupId?
+- ⚠️ **전체 수정 API**: 개별 PUT N번 vs 일괄 PUT 1번 (body에 배열)?
+- ⚠️ **다이얼로그 UI**: MUI Dialog 사용, 버튼 위치/스타일
+- ⚠️ **수정 폼 재사용**: 기존 수정 폼 재사용 가능한지
+
+### 구현 시 고려사항
+
+- 반복 그룹 식별 로직: `src/utils/repeatGroupUtils.ts` 신규 생성
+- 단일/전체 수정 모드 상태 관리: `useState` 또는 context
+- 다이얼로그 컴포넌트: App.tsx에 추가 또는 별도 컴포넌트
+- API 호출 최적화: 전체 수정 시 일괄 처리 고려
+
+### 테스트 코드 구조 제안
+
+```typescript
+describe('FEATURE4: 반복 일정 수정', () => {
+ describe('Story 1: 단일 반복 일정 수정', () => {
+ it('TC-4-1-1: 단일 수정 선택 시 해당 일정만 수정되고 반복 속성이 제거된다', ...);
+ it('TC-4-1-2: 단일 수정 후 해당 일정의 반복 아이콘이 사라진다', ...);
+ it('TC-4-1-3: 단일 수정 시 나머지 반복 일정은 유지된다', ...);
+ });
+
+ describe('Story 2: 전체 반복 일정 수정', () => {
+ it('TC-4-2-1: 전체 수정 선택 시 모든 반복 일정이 수정되고 반복 속성이 유지된다', ...);
+ it('TC-4-2-2: 전체 수정 후 모든 일정의 반복 아이콘이 유지된다', ...);
+ it('TC-4-2-3: 전체 수정 시 반복 유형이 유지된다', ...);
+ });
+
+ describe('Story 3: 수정 확인 다이얼로그 표시', () => {
+ it('TC-4-3-1: 반복 일정 수정 시 다이얼로그가 표시된다', ...);
+ it('TC-4-3-2: 다이얼로그에서 "예" 선택 시 단일 수정 모드로 진행된다', ...);
+ it('TC-4-3-3: 다이얼로그에서 "아니오" 선택 시 전체 수정 모드로 진행된다', ...);
+ it('TC-4-3-4: 일반 일정 수정 시 다이얼로그가 표시되지 않는다', ...);
+ });
+});
+```
+
+---
+
+**테스트 설계 완료일**: 2025-10-30
+**총 테스트 케이스**: 10개 (Story 1: 3개, Story 2: 3개, Story 3: 4개)
+**테스트 유형**: 통합 테스트 10개
diff --git a/.cursor/outputs/3-integration-test-design/feature5-test-design.md b/.cursor/outputs/3-integration-test-design/feature5-test-design.md
new file mode 100644
index 00000000..753bd776
--- /dev/null
+++ b/.cursor/outputs/3-integration-test-design/feature5-test-design.md
@@ -0,0 +1,248 @@
+# 🧪 테스트 설계서: Feature 5 - 반복 일정 삭제
+
+## 1. 테스트 목적
+
+반복 일정 삭제 시 단일 삭제와 전체 삭제가 올바르게 동작하는지 검증합니다.
+
+- 다이얼로그가 적절히 표시되고 사용자 선택을 처리하는지 확인
+- 단일 삭제 시 선택한 일정만 삭제되고 나머지는 유지되는지 확인
+- 전체 삭제 시 모든 반복 일정이 삭제되는지 확인
+- 일반 일정 삭제 시 다이얼로그가 표시되지 않는지 확인
+
+## 2. 테스트 범위
+
+### 포함
+
+- 반복 일정 삭제 시 다이얼로그 표시 및 선택 처리
+- 단일 삭제 모드: 선택한 일정만 삭제, 나머지 유지
+- 전체 삭제 모드: 모든 반복 일정 삭제
+- 일반 일정 삭제 시 다이얼로그 미표시 및 즉시 삭제
+
+### 제외
+
+- 반복 일정 생성 로직 (Feature 1에서 검증)
+- 반복 일정 시각적 구분 (Feature 2에서 검증)
+- 반복 일정 종료 날짜 (Feature 3에서 검증)
+- 반복 일정 수정 (Feature 4에서 검증)
+- 백엔드 API 실제 통신 (MSW로 모킹)
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | ------------------------------------------- |
+| 통합 테스트 | UI 다이얼로그와 비즈니스 로직 간 상호작용 |
+| 단위 테스트 | 반복 그룹 식별 로직 등 순수 함수 (Feature 4에서 구현됨) |
+
+## 4. 테스트 시나리오
+
+### Story 1: 반복 일정 삭제 모드 선택
+
+| TC ID | 설명 | 입력 | 사용자 행동 | 기대 결과 | 테스트 유형 |
+| -------- | ------------------------------------------------- | --------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------- |
+| TC-5-1-1 | 반복 일정 삭제 클릭 시 확인 다이얼로그가 표시된다 | - 반복 일정 (매주 월요일) 존재 | 1. 반복 일정 클릭 2. 삭제 버튼 클릭 | - 다이얼로그 표시됨 - "해당 일정만 삭제하시겠어요?" 텍스트 존재 - "예" 버튼 존재 - "아니오" 버튼 존재 | 통합 |
+| TC-5-1-2 | 일반 일정 삭제 시 다이얼로그 없이 즉시 삭제된다 | - 일반 일정 (repeat.type = 'none') 존재 | 1. 일반 일정 클릭 2. 삭제 버튼 클릭 | - 다이얼로그 표시되지 않음 - 즉시 DELETE API 호출 - "일정이 삭제되었습니다" 스낵바 표시 | 통합 |
+
+### Story 2: 단일 일정 삭제
+
+| TC ID | 설명 | 입력 | 사용자 행동 | 기대 결과 | 테스트 유형 |
+| -------- | ---------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ----------- |
+| TC-5-2-1 | 다이얼로그에서 '예' 선택 시 해당 일정만 삭제된다 | - 반복 일정 3개 존재 (매주 월요일) - IDs: ["repeat-1", "repeat-2", "repeat-3"] - 제목: "회의" | 1. 두 번째 일정(repeat-2) 클릭 2. 삭제 버튼 클릭 3. "예" 버튼 클릭 | - DELETE /api/events/repeat-2 호출 - DELETE 호출 횟수 = 1 - "일정이 삭제되었습니다" 스낵바 표시 | 통합 |
+| TC-5-2-2 | 단일 삭제 후 삭제 성공 메시지가 표시된다 | - 단일 삭제 완료 | 1. 삭제 완료 후 대기 | - "일정이 삭제되었습니다" 텍스트 스낵바 표시 - getByText("일정이 삭제되었습니다") 존재 | 통합 |
+| TC-5-2-3 | 단일 삭제 후 캘린더에서 해당 일정만 사라진다 | - 매주 목요일 반복 일정 3개 - 제목: "운동" | 1. 첫 번째 일정 삭제 (단일 삭제) 2. 캘린더 확인 | - "운동" 텍스트가 2개만 표시됨 - getAllByText("운동").length = 2 (또는 반복 아이콘 기준으로 카운트 = 2) | 통합 |
+
+### Story 3: 전체 반복 일정 삭제
+
+| TC ID | 설명 | 입력 | 사용자 행동 | 기대 결과 | 테스트 유형 |
+| -------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ----------- |
+| TC-5-3-1 | 다이얼로그에서 '아니오' 선택 시 전체 반복 일정이 삭제된다 | - 반복 일정 3개 존재 (매주 수요일) - IDs: ["repeat-a", "repeat-b", "repeat-c"] - 제목: "스터디" | 1. 첫 번째 일정(repeat-a) 클릭 2. 삭제 버튼 클릭 3. "아니오" 버튼 클릭 | - DELETE /api/events/{id} 호출 3번 (각 ID마다) - 또는 DELETE /api/events-list 호출 1번 - "일정이 삭제되었습니다" 스낵바 표시 | 통합 |
+| TC-5-3-2 | 전체 삭제 후 삭제 성공 메시지가 표시된다 | - 전체 삭제 완료 | 1. 삭제 완료 후 대기 | - "일정이 삭제되었습니다" 텍스트 스낵바 표시 - getByText("일정이 삭제되었습니다") 존재 | 통합 |
+| TC-5-3-3 | 전체 삭제 후 캘린더에서 모든 반복 일정이 사라진다 | - 매주 금요일 반복 일정 4개 - 제목: "미팅" | 1. 아무 일정 선택하여 전체 삭제 2. 캘린더 확인 | - "미팅" 텍스트가 캘린더에서 완전히 사라짐 - queryByText("미팅") = null | 통합 |
+
+## 5. 테스트 데이터
+
+### Mock Repeating Events (For Deletion)
+
+```typescript
+const mockRepeatingEventsForDeletion: Event[] = [
+ {
+ id: 'repeat-del-1',
+ title: '회의',
+ date: '2025-10-06', // 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 회의',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'repeat-del-2',
+ title: '회의',
+ date: '2025-10-13', // 월요일 (1주 후)
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 회의',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'repeat-del-3',
+ title: '회의',
+ date: '2025-10-20', // 월요일 (2주 후)
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 회의',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+];
+
+const mockNormalEventForDeletion: Event = {
+ id: 'normal-del-1',
+ title: '일반 일정',
+ date: '2025-10-07',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '일반 일정 설명',
+ location: '장소',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+};
+```
+
+### Mock API Responses
+
+```typescript
+// Single deletion: DELETE /api/events/:id
+const mockDeleteSingleResponse = {
+ status: 200,
+ body: { success: true },
+};
+
+// Group deletion: DELETE /api/events-list (optional, depends on backend design)
+const mockDeleteGroupResponse = {
+ status: 200,
+ body: { success: true, deletedCount: 3 },
+};
+```
+
+## 6. 검증 포인트
+
+### Story 1: 반복 일정 삭제 모드 선택
+
+| TC ID | 검증 항목 | 검증 방법 |
+| -------- | ------------------------------------------- | ----------------------------------------------------------- |
+| TC-5-1-1 | 다이얼로그 표시 | `getByText("해당 일정만 삭제하시겠어요?")` |
+| | "예" 버튼 존재 | `getByRole('button', { name: '예' })` |
+| | "아니오" 버튼 존재 | `getByRole('button', { name: '아니오' })` |
+| TC-5-1-2 | 다이얼로그 미표시 | `queryByText("해당 일정만 삭제하시겠어요?")` = null |
+| | DELETE API 즉시 호출 | MSW handler 또는 mockFetch 검증 |
+| | 스낵바 메시지 표시 | `getByText("일정이 삭제되었습니다")` |
+
+### Story 2: 단일 일정 삭제
+
+| TC ID | 검증 항목 | 검증 방법 |
+| -------- | ------------------------------------------- | ----------------------------------------------------------- |
+| TC-5-2-1 | DELETE API 호출 횟수 = 1 | `mockFetch.mock.calls.filter(call => call[0].includes('/api/events/') && call[1]?.method === 'DELETE').length` = 1 |
+| | DELETE URL에 특정 ID 포함 | `mockFetch.mock.calls.find(call => call[0].includes('/api/events/repeat-2'))` 존재 |
+| | 스낵바 메시지 표시 | `getByText("일정이 삭제되었습니다")` |
+| TC-5-2-2 | 스낵바 메시지 표시 | `getByText("일정이 삭제되었습니다")` |
+| TC-5-2-3 | 캘린더에 특정 이벤트만 제거됨 | `getAllByText("운동").length` = 2 (이전 3개에서 1개 삭제) |
+| | 또는 반복 아이콘 개수 | `getAllByLabelText("반복 일정").length` = 2 |
+
+### Story 3: 전체 반복 일정 삭제
+
+| TC ID | 검증 항목 | 검증 방법 |
+| -------- | ------------------------------------------- | ----------------------------------------------------------- |
+| TC-5-3-1 | DELETE API 호출 횟수 = 그룹 내 이벤트 개수 | `mockFetch.mock.calls.filter(call => call[1]?.method === 'DELETE').length` = 3 |
+| | 모든 ID에 대해 DELETE 호출 | repeat-a, repeat-b, repeat-c 각각 호출 확인 |
+| | 스낵바 메시지 표시 | `getByText("일정이 삭제되었습니다")` |
+| TC-5-3-2 | 스낵바 메시지 표시 | `getByText("일정이 삭제되었습니다")` |
+| TC-5-3-3 | 캘린더에서 모든 반복 일정 제거됨 | `queryByText("미팅")` = null |
+| | 또는 반복 아이콘이 완전히 사라짐 | `queryAllByLabelText("반복 일정").length` = 0 (해당 그룹 기준) |
+
+## 7. 테스트 구조
+
+```typescript
+describe('Feature 5: 반복 일정 삭제', () => {
+ beforeEach(() => {
+ // MSW 설정
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({ events: mockRepeatingEventsForDeletion });
+ }),
+ http.delete('/api/events/:id', ({ params }) => {
+ return HttpResponse.json({ success: true });
+ })
+ );
+ });
+
+ describe('Story 1: 반복 일정 삭제 모드 선택', () => {
+ it('TC-5-1-1: 반복 일정 삭제 클릭 시 확인 다이얼로그가 표시된다', async () => {
+ // ...
+ });
+
+ it('TC-5-1-2: 일반 일정 삭제 시 다이얼로그 없이 즉시 삭제된다', async () => {
+ // ...
+ });
+ });
+
+ describe('Story 2: 단일 일정 삭제', () => {
+ it('TC-5-2-1: 다이얼로그에서 "예" 선택 시 해당 일정만 삭제된다', async () => {
+ // ...
+ });
+
+ it('TC-5-2-2: 단일 삭제 후 삭제 성공 메시지가 표시된다', async () => {
+ // ...
+ });
+
+ it('TC-5-2-3: 단일 삭제 후 캘린더에서 해당 일정만 사라진다', async () => {
+ // ...
+ });
+ });
+
+ describe('Story 3: 전체 반복 일정 삭제', () => {
+ it('TC-5-3-1: 다이얼로그에서 "아니오" 선택 시 전체 반복 일정이 삭제된다', async () => {
+ // ...
+ });
+
+ it('TC-5-3-2: 전체 삭제 후 삭제 성공 메시지가 표시된다', async () => {
+ // ...
+ });
+
+ it('TC-5-3-3: 전체 삭제 후 캘린더에서 모든 반복 일정이 사라진다', async () => {
+ // ...
+ });
+ });
+});
+```
+
+## 8. 주의사항
+
+1. **반복 그룹 식별**: Feature 4에서 구현된 `findRepeatGroup` 함수를 재사용할 수 있습니다.
+2. **다이얼로그 조건**: `repeat.type !== 'none'`인 경우에만 다이얼로그를 표시해야 합니다.
+3. **삭제 API 설계**:
+ - 단일 삭제: `DELETE /api/events/{id}`
+ - 전체 삭제: 각 ID마다 개별 DELETE 호출 또는 `DELETE /api/events-list` (백엔드 구현에 따라 결정)
+4. **MSW 모킹**: 테스트 중 실제 API 호출을 방지하기 위해 MSW를 사용합니다.
+5. **스낵바 검증**: `waitFor`를 사용하여 스낵바 메시지가 표시될 때까지 대기해야 합니다.
+6. **캘린더 렌더링**: 삭제 후 `fetchEvents()`가 호출되고 캘린더가 리렌더링되는지 확인해야 합니다.
+
+## 9. 선행 조건
+
+- Feature 4 (반복 일정 수정)의 `findRepeatGroup` 유틸리티가 구현되어 있어야 합니다.
+- 삭제 버튼이 UI에 존재하고 클릭 가능해야 합니다.
+- MSW가 설정되어 있어야 합니다.
+
+## 10. 성공 기준
+
+- ✅ 모든 8개 테스트 케이스가 통과합니다.
+- ✅ Linter 에러가 없습니다.
+- ✅ 테스트 커버리지가 90% 이상입니다.
+- ✅ Integration Test Evaluator 점수가 90점 이상입니다.
diff --git a/.cursor/outputs/4-integration-to-unit/feature1-breakdown-test-design.md b/.cursor/outputs/4-integration-to-unit/feature1-breakdown-test-design.md
new file mode 100644
index 00000000..cd85a92a
--- /dev/null
+++ b/.cursor/outputs/4-integration-to-unit/feature1-breakdown-test-design.md
@@ -0,0 +1,91 @@
+# 🧪 테스트 설계서 - FEATURE1 (반복 유형 선택) 단위 테스트
+
+## 1. 테스트 목적
+
+반복 일정 생성 기능을 구성하는 내부 책임(상태 토글, 반복 유형 매핑, 반복 일정 생성 로직)이 명세에 따라 정확히 동작하는지 단위 수준에서 검증한다. UI 표시 여부와 무관하게, 해당 로직이 올바른 상태값과 출력 데이터를 산출하여 이후 통합 단계에서 신뢰할 수 있는 기반을 제공하는 것을 목표로 한다.
+
+## 2. 테스트 범위
+
+- 포함
+ - 반복 설정 토글 로직 (`isRepeating`, `repeatType`, `repeatOptionsVisibility`)
+ - 반복 유형 선택 값과 내부 상태/페이로드 매핑 로직
+ - 반복 일정 생성 유틸 (일/주/월/년, 31일 및 윤년 예외 처리 포함)
+ - 반복 미선택 시 단일 일정 생성 분기
+- 제외
+ - 실제 UI 렌더링 및 드롭다운 컴포넌트 동작 (통합 테스트에서 검증)
+ - 서버 API 통신 및 네트워크 에러 처리
+ - 토스트/알림 등 사용자 피드백 표현
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | ----------------------------- |
+| 단위 테스트 | 상태/유틸 함수 책임 검증 |
+| 통합 테스트 | (별도 설계) |
+| E2E 테스트 | (본 설계 범위에 포함되지 않음) |
+
+## 4. 테스트 시나리오
+
+| 시나리오 ID | 설명 | 입력/조건 | 기대 결과 | 테스트 유형 |
+| ----------- | ------------------------------------------------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------- |
+| U-1 | 반복 설정을 활성/비활성화할 때 상태와 가시성 플래그가 올바르게 갱신된다 | 초기 상태, 토글 입력(on/off) | `isRepeating` 값이 토글 입력과 일치, off 시 `repeatType = 'none'`, 가시성 false | 단위 |
+| U-2 | 반복 설정 재토글 시 연속 동작이 누락 없이 유지된다 | on → off → on 순차 토글 | 각 단계에서 상태가 지정된 순서대로 전환되고, 마지막 on 시 이전 `repeatType` 복원(없다면 기본값) | 단위 |
+| U-3 | 반복 유형 선택 시 내부 상태/페이로드가 매핑된다 | type 선택값(daily/weekly/monthly/yearly) | 각 선택값에 대응하는 상태(`repeatType`)와 저장 페이로드 `repeat.type`이 일치 | 단위 |
+| U-4 | 반복 유형 옵션 목록을 제공하는 헬퍼가 명세된 4개 옵션을 반환한다 | 없음 | 배열에 daily/weekly/monthly/yearly 네 가지 옵션이 순서 유지로 포함 | 단위 |
+| U-5 | 일/주 반복 생성 유틸이 지정된 기간 내 모든 날짜를 생성한다 | 시작일, 기간/횟수, type=daily/weekly | 생성된 이벤트 목록이 기간 동안 모든 날짜/주를 포함하고 누락 없음 | 단위 |
+| U-6 | 월 반복 생성 시 31일이 없는 달은 제외된다 | 시작일=1월31일, type=monthly, 생성 기간(예: 6개월) | 2월·4월·6월·9월·11월이 결과에 포함되지 않고 31일이 존재하는 월만 생성 | 단위 |
+| U-7 | 월 반복 생성 시 31일 이후 첫 유효 달이 존재하면 올바른 날짜로 이어진다 | 시작일=1월31일, type=monthly, 생성 기간(예: 3개월) | 3월 31일, 5월 31일 등 유효 월이 순차적으로 포함 | 단위 |
+| U-8 | 윤년 2월 29일 연간 반복은 평년을 건너뛴다 | 시작일=2024-02-29, type=yearly, 생성 기간(예: 5년) | 2025~2027 결과 없음, 2028-02-29 포함 | 단위 |
+| U-9 | 반복 설정 없이 저장 시 일반 일정만 생성된다 | `isRepeating=false`, 기본 일정 입력 | 반환 결과가 단일 일정이며 `repeat.type === 'none'` | 단위 |
+
+### describe/it 구조 초안
+
+```ts
+describe('repeatSettingsManager', () => {
+ describe('toggleRepeat', () => {
+ it('toggles isRepeating true and shows options when enabled');
+ it('resets repeatType to "none" and hides options when disabled');
+ it('restores previous repeatType when re-enabled if available');
+ });
+
+ describe('getRepeatOptions', () => {
+ it('returns four repeat options in defined order');
+ });
+
+ describe('applyRepeatType', () => {
+ it('sets repeatType and payload type for daily');
+ it('sets repeatType and payload type for weekly');
+ it('sets repeatType and payload type for monthly');
+ it('sets repeatType and payload type for yearly');
+ });
+});
+
+describe('recurringEventGenerator', () => {
+ describe('generateDailyEvents', () => {
+ it('creates events for every day within range');
+ });
+
+ describe('generateWeeklyEvents', () => {
+ it('creates events for each week on same weekday');
+ });
+
+ describe('generateMonthlyEvents', () => {
+ it('skips months without the target day when day=31');
+ it('continues with next valid month that has the target day');
+ });
+
+ describe('generateYearlyEvents', () => {
+ it('excludes non-leap years for Feb 29 start');
+ it('includes next leap year with Feb 29 date');
+ });
+
+ describe('generateSingleEvent', () => {
+ it('returns only the base event when repeat disabled');
+ });
+});
+```
+
+## 5. 비고
+
+- 반복 일정 생성 유틸은 아직 구현되지 않았으므로, 위 테스트 설계를 기준으로 유틸 함수 책임을 정의하고 구현 시 본 테스트 명세를 참고해야 한다.
+- 반복 옵션 복원 로직(토글 재활성화 시 기존 선택 유지) 여부가 명확하지 않으므로, 실제 구현 방향에 따라 U-2 기대 결과를 조정할 필요가 있다. 구현 전에 제품 요구사항을 재확인해야 한다.
diff --git a/.cursor/outputs/4-integration-to-unit/feature2-breakdown-test-design.md b/.cursor/outputs/4-integration-to-unit/feature2-breakdown-test-design.md
new file mode 100644
index 00000000..8042a841
--- /dev/null
+++ b/.cursor/outputs/4-integration-to-unit/feature2-breakdown-test-design.md
@@ -0,0 +1,367 @@
+# Unit Candidates for "FEATURE2: 반복 일정 표시"
+
+**분석 기준 문서**:
+
+- 통합 테스트 설계: `feature2-test-design.md`
+- 통합 테스트 코드: `feature2-integration.spec.tsx`
+- 단위 테스트 체크리스트: `/checklists/unit-test.md`
+
+**기능 개요**: 캘린더 뷰와 일정 목록에서 반복 일정을 아이콘으로 시각적으로 구분
+
+---
+
+## 📘 참고 체크리스트 기준
+
+`/checklists/unit-test.md`의 핵심 원칙:
+
+1. **분리할 가치가 있는 로직인가?**
+
+ - [x] 조건/분기 로직이 있다
+ - [x] 외부 의존성이 없다 (DOM/React 없이 독립 실행)
+ - [x] 입력과 출력이 명확하다
+
+2. **통합 테스트에서 충분히 검증되지 않았는가?**
+ - [x] 여러 Flow에서 재사용되는 로직이다
+ - [x] 실패 시 사용자 영향이 크다
+
+---
+
+## 🎯 분석 결과
+
+### 통합 테스트에서 식별된 핵심 로직
+
+통합 테스트(feature2-integration.spec.tsx)를 분석한 결과, 다음과 같은 패턴이 반복됩니다:
+
+```typescript
+// 반복적으로 나타나는 로직
+event.repeat.type !== 'none'; // 반복 일정 판별
+```
+
+이 로직은:
+
+- TC-2-1-1, TC-2-1-2 (캘린더 뷰)
+- TC-2-2-1 (일정 목록)
+- TC-2-3-1, TC-2-3-2 (일관성)
+
+**총 5개의 테스트 케이스에서 암묵적으로 사용**됩니다.
+
+### 체크리스트 검증
+
+| 기준 | 평가 | 비고 |
+| ------------------------------ | ---- | ------------------------ |
+| 조건/분기 로직 있음 | ✅ | `repeat.type !== 'none'` |
+| 계산/변환 수행 | ❌ | 단순 boolean 반환 |
+| 외부 의존성 없음 | ✅ | 순수 함수 가능 |
+| 입출력 명확 | ✅ | Event → boolean |
+| 통합 테스트로 내부 동작 불가시 | ✅ | 아이콘 표시 결과만 검증 |
+| 여러 Flow에서 재사용 | ✅ | 5개 테스트에서 사용 |
+| 실패 시 사용자 영향 큼 | ✅ | 잘못된 아이콘 표시 |
+
+**결론**: 4개 중 3개 충족 → ✅ **단위 테스트 후보**
+
+---
+
+## 1. isRepeatingEvent (Utility)
+
+**Type**: Utility
+
+**Responsibilities**:
+이벤트 객체를 받아 반복 일정 여부를 판별하는 순수 함수를 제공한다.
+
+**Methods / Interfaces**:
+
+```typescript
+/**
+ * 이벤트가 반복 일정인지 확인
+ *
+ * @param event - 검증할 이벤트 객체
+ * @returns 반복 일정이면 true, 일반 일정(none)이면 false
+ *
+ * @example
+ * isRepeatingEvent({ repeat: { type: 'daily' } }) // true
+ * isRepeatingEvent({ repeat: { type: 'weekly' } }) // true
+ * isRepeatingEvent({ repeat: { type: 'none' } }) // false
+ * isRepeatingEvent({}) // false
+ *
+ * @throws 없음 - null/undefined 안전
+ */
+export function isRepeatingEvent(event: Event | EventForm): boolean;
+```
+
+**Relations**:
+
+- **사용처**: UI 컴포넌트 (CalendarView, EventList)에서 아이콘 렌더링 조건 판단
+- **의존성**: 없음 (완전히 독립적인 순수 함수)
+
+**단위 테스트 케이스 (9개)**:
+
+| Test ID | Name | Input | Expected | Category |
+| --------- | ---------------------- | --------------------------------- | -------- | ----------- |
+| TC-U2-1-1 | daily 반복 일정 판별 | `{ repeat: { type: 'daily' } }` | `true` | 정상 |
+| TC-U2-1-2 | weekly 반복 일정 판별 | `{ repeat: { type: 'weekly' } }` | `true` | 정상 |
+| TC-U2-1-3 | monthly 반복 일정 판별 | `{ repeat: { type: 'monthly' } }` | `true` | 정상 |
+| TC-U2-1-4 | yearly 반복 일정 판별 | `{ repeat: { type: 'yearly' } }` | `true` | 정상 |
+| TC-U2-1-5 | 일반 일정(none) 판별 | `{ repeat: { type: 'none' } }` | `false` | 정상 |
+| TC-U2-1-6 | repeat 속성 없음 | `{}` | `false` | 엣지 케이스 |
+| TC-U2-1-7 | repeat.type 없음 | `{ repeat: {} }` | `false` | 엣지 케이스 |
+| TC-U2-1-8 | null 입력 | `null` | `false` | 엣지 케이스 |
+| TC-U2-1-9 | undefined 입력 | `undefined` | `false` | 엣지 케이스 |
+
+**구현 예시**:
+
+```typescript
+// src/utils/eventTypeChecker.ts
+import type { Event, EventForm } from '../types';
+
+export function isRepeatingEvent(event: Event | EventForm | null | undefined): boolean {
+ return event?.repeat?.type !== undefined && event.repeat.type !== 'none';
+}
+```
+
+**테스트 예시**:
+
+```typescript
+// src/__tests__/unit/eventTypeChecker.spec.ts
+import { describe, it, expect } from 'vitest';
+import { isRepeatingEvent } from '../../utils/eventTypeChecker';
+
+describe('isRepeatingEvent', () => {
+ it('TC-U2-1-1: daily 반복 일정을 true로 판별', () => {
+ const event = { repeat: { type: 'daily', interval: 1 } };
+ expect(isRepeatingEvent(event as any)).toBe(true);
+ });
+
+ it('TC-U2-1-5: 일반 일정(none)을 false로 판별', () => {
+ const event = { repeat: { type: 'none', interval: 1 } };
+ expect(isRepeatingEvent(event as any)).toBe(false);
+ });
+
+ it('TC-U2-1-6: repeat 속성 없을 때 false 반환', () => {
+ const event = {};
+ expect(isRepeatingEvent(event as any)).toBe(false);
+ });
+
+ // ... 나머지 6개 테스트
+});
+```
+
+---
+
+## 🚫 Unit Test 범위에서 제외된 항목
+
+### ❌ React Components (통합 테스트 대상)
+
+**이유**: DOM 의존, 렌더링 로직, props 전달 중심 → 통합 테스트로 충분히 검증
+
+- `CalendarView` - 월간/주간 뷰 렌더링 컴포넌트
+- `EventList` - 일정 목록 렌더링 컴포넌트
+- `RepeatIcon` - 반복 아이콘 컴포넌트
+- `EventItem` - 개별 일정 항목 컴포넌트
+
+**체크리스트 불만족**:
+
+- [x] DOM 의존성 있음
+- [x] 입출력이 React Element (명확하지 않음)
+- [x] 단순 렌더링, props 전달
+
+### ❌ React Hooks (통합 테스트 대상)
+
+**이유**: React state/lifecycle 의존 → 컴포넌트와 함께 통합 테스트
+
+- `useCalendarView` - 캘린더 뷰 상태 관리
+- `useEventList` - 이벤트 목록 상태 관리
+
+**체크리스트 불만족**:
+
+- [x] React 의존성 있음
+- [x] 외부 효과(side effect) 포함
+
+### ❌ 추가 Utility 함수 (불필요)
+
+**이유**: 로직이 너무 단순하거나 단순 조합 → 단위 테스트 가치 낮음
+
+#### `getRepeatType` (제외)
+
+```typescript
+// ❌ 단순 속성 접근 - 단위 테스트 불필요
+function getRepeatType(event: Event): RepeatType {
+ return event?.repeat?.type || 'none';
+}
+```
+
+**제외 이유**:
+
+- 조건/분기 없음 (단순 접근자)
+- 계산/변환 없음
+- 테스트 가치 < 유지보수 비용
+
+#### `filterRepeatingEvents` (제외)
+
+```typescript
+// ❌ 배열 메서드 + 기존 함수 조합 - 단위 테스트 불필요
+function filterRepeatingEvents(events: Event[]): Event[] {
+ return events.filter(isRepeatingEvent);
+}
+```
+
+**제외 이유**:
+
+- `isRepeatingEvent` + `Array.filter` 단순 조합
+- 고유 로직 없음
+- 통합 테스트로 충분
+
+#### `getIconAriaLabel` (제외)
+
+```typescript
+// ❌ 상수 반환 - 단위 테스트 불필요
+function getIconAriaLabel(): string {
+ return '반복 일정';
+}
+```
+
+**제외 이유**:
+
+- 로직 없음 (상수 반환)
+- 테스트로 얻는 가치 없음
+
+### ❌ Type Definitions (타입 체크 영역)
+
+- `Event` - 이벤트 타입 정의
+- `EventForm` - 이벤트 폼 타입 정의
+- `RepeatType` - 반복 유형 타입 정의
+
+**이유**: TypeScript 컴파일러가 검증
+
+---
+
+## 📊 최종 요약
+
+### 단위 테스트 후보: **1개 (Utility)**
+
+| Name | Type | Priority | 테스트 수 | 구현 비용 | 효과 |
+| ------------------ | ------- | -------- | --------- | --------- | -------------------- |
+| `isRepeatingEvent` | Utility | 높음 | 9개 | 낮음 | 재사용성 ↑, 안정성 ↑ |
+
+### 의존 관계 다이어그램
+
+```
+isRepeatingEvent (Utility - 단위 테스트)
+ ↓
+[React Components] ← 통합 테스트 영역
+ ↓
+User Interface
+```
+
+### 구현 파일 구조
+
+```
+src/
+ utils/
+ eventTypeChecker.ts # isRepeatingEvent 구현
+ __tests__/
+ unit/
+ eventTypeChecker.spec.ts # 단위 테스트 (9개)
+```
+
+---
+
+## 💡 권장 사항
+
+### 🟢 구현 권장
+
+**대상**: `isRepeatingEvent` 함수 1개
+
+**우선순위**: 중간 (필수 아님, 권장)
+
+**이유**:
+
+1. ✅ 조건/분기 로직 포함 (체크리스트 기준 충족)
+2. ✅ 여러 곳에서 재사용됨 (5개 테스트 케이스)
+3. ✅ 실패 시 사용자 영향 큼 (잘못된 아이콘 표시)
+4. ✅ 순수 함수로 테스트 작성 매우 간단
+5. ✅ 구현 비용 낮음 (함수 1개, 테스트 9개)
+
+**효과**:
+
+- 코드 재사용성 향상
+- 통합 테스트 + 단위 테스트 이중 검증
+- 리팩터링 시 안전망 제공
+- 다른 Feature에서도 재사용 가능
+
+### 🟡 추가 구현 불필요
+
+**대상**: `getRepeatType`, `filterRepeatingEvents`, `getIconAriaLabel`
+
+**이유**:
+
+- 로직이 너무 단순 (단순 접근자, 배열 메서드 조합, 상수 반환)
+- 테스트 가치 < 유지보수 비용
+- 통합 테스트로 충분히 검증됨
+
+**조건부 추가**: 아래 조건 충족 시에만 고려
+
+- [ ] 로직이 복잡해짐
+- [ ] 다른 Feature에서도 필요
+- [ ] 성능 최적화 필요
+
+---
+
+## 🔍 Feature2의 특수성
+
+### UI 중심 기능의 단위 테스트 범위
+
+Feature2는 **시각적 표시 중심 기능**으로:
+
+- 복잡한 비즈니스 로직 거의 없음
+- 대부분 React 컴포넌트 렌더링
+- **통합 테스트 93점(Excellent)**으로 충분히 검증됨
+
+### 단위 테스트의 역할
+
+이런 UI 중심 기능에서 단위 테스트는:
+
+- ✅ **핵심 판별 로직 추출** → 재사용성 확보
+- ✅ **경계 조건 명확히 검증** → 엣지 케이스 커버
+- ❌ 모든 로직을 단위로 쪼개지 않음 → 과도한 추출 방지
+
+### 체크리스트 기준 적용
+
+`/checklists/unit-test.md` 기준:
+
+> "입력과 출력이 명확하고, 통합 테스트로 내부 동작이 보이지 않는 로직은 단위로 뽑아라."
+
+**Feature2 적용**:
+
+- ✅ `isRepeatingEvent`: 입출력 명확, 통합 테스트가 "아이콘 표시" 결과만 검증
+- ❌ 나머지: 너무 단순하거나 통합 테스트로 충분
+
+---
+
+## ✅ 결론
+
+### 단위 테스트 후보: 1개
+
+**`isRepeatingEvent` (Utility)**
+
+- Type: 순수 함수
+- 테스트 케이스: 9개
+- 구현 비용: 낮음
+- 효과: 재사용성 ↑, 안정성 ↑
+
+### 제외된 항목
+
+- **React Components/Hooks**: 통합 테스트 영역 (7개)
+- **추가 Utility**: 로직 너무 단순 (3개)
+- **Type Definitions**: TypeScript 영역 (3개)
+
+### 권장 사항
+
+1. **우선 구현**: `isRepeatingEvent` 1개 구현
+2. **통합 테스트 유지**: 현재 93점 유지 (매우 우수)
+3. **점진적 추가**: 필요 시 추가 Utility 고려
+
+---
+
+**작성 기준**: `/checklists/unit-test.md`
+**작성일**: 2025-10-30
+**통합 테스트 품질**: 93/100 (Excellent)
diff --git a/.cursor/outputs/4-integration-to-unit/feature3-breakdown-test-design.md b/.cursor/outputs/4-integration-to-unit/feature3-breakdown-test-design.md
new file mode 100644
index 00000000..78338570
--- /dev/null
+++ b/.cursor/outputs/4-integration-to-unit/feature3-breakdown-test-design.md
@@ -0,0 +1,127 @@
+# Feature 3: 반복 일정 종료 조건 - Unit Test Candidates
+
+## 분석 대상
+- Integration Test: `src/__tests__/integration/feature3-integration.spec.tsx`
+- Test Design: `.cursor/outputs/3-integration-test-design/feature3-test-design.md`
+
+---
+
+## 유닛 테스트 후보 식별 결과
+
+### ✅ 후보 1: 종료 날짜 검증 함수 (`validateRepeatEndDate`)
+
+**식별 근거:**
+- TC-3-2-1에서 "종료 날짜가 시작 날짜보다 이전이면 에러 메시지 표시" 검증
+- 이는 순수한 날짜 비교 로직으로, 유닛 테스트로 분리 가능
+
+**함수 시그니처 (예상):**
+```typescript
+function validateRepeatEndDate(
+ startDate: string,
+ endDate: string | undefined
+): { valid: boolean; error?: string }
+```
+
+**유닛 테스트 시나리오:**
+1. 종료 날짜가 시작 날짜보다 이전 → `{ valid: false, error: "종료 날짜는 시작 날짜 이후여야 합니다" }`
+2. 종료 날짜가 시작 날짜와 같음 → `{ valid: true }`
+3. 종료 날짜가 시작 날짜보다 이후 → `{ valid: true }`
+4. 종료 날짜가 undefined → `{ valid: true }` (기본값 적용)
+5. 종료 날짜가 2025-12-31 초과 → `{ valid: true }` (자동 제한, 경고 없음)
+6. 잘못된 날짜 형식 → `{ valid: false, error: "..." }`
+
+---
+
+### ✅ 후보 2: 종료 날짜 기본값 적용 함수 (`getRepeatEndDate`)
+
+**식별 근거:**
+- TC-3-1-4에서 "종료 날짜 미입력 시 기본값(2025-12-31)까지 생성" 검증
+- TC-3-2-3에서 "종료 날짜가 2025-12-31 초과 시 2025-12-31까지만 생성" 검증
+- 이는 순수한 날짜 처리 로직
+
+**함수 시그니처 (예상):**
+```typescript
+function getRepeatEndDate(
+ endDate: string | undefined
+): string
+```
+
+**유닛 테스트 시나리오:**
+1. `endDate === undefined` → `"2025-12-31"`
+2. `endDate === null` → `"2025-12-31"`
+3. `endDate === ""` → `"2025-12-31"`
+4. `endDate === "2025-10-31"` → `"2025-10-31"` (그대로 반환)
+5. `endDate === "2025-12-31"` → `"2025-12-31"` (경계값)
+6. `endDate === "2026-01-01"` → `"2025-12-31"` (최대값 제한)
+7. `endDate === "2027-12-31"` → `"2025-12-31"` (최대값 제한)
+8. `endDate === "2024-12-31"` → `"2024-12-31"` (과거 날짜는 허용, 시작 날짜 검증은 별도)
+
+---
+
+### ✅ 후보 3: 종료 날짜까지 반복 일정 생성 함수 (`generateRecurringEventsUntilEndDate`)
+
+**식별 근거:**
+- TC-3-1-3, TC-3-2-2, TC-3-2-3에서 종료 날짜에 따른 생성 개수 검증
+- 기존 `generateRecurringEvents` 확장 또는 수정
+
+**함수 시그니처 (예상):**
+```typescript
+function generateRecurringEventsUntilEndDate(
+ baseEvent: Event | EventForm,
+ endDate: string
+): Event[]
+```
+
+**유닛 테스트 시나리오:**
+1. 매일 반복, 10/1~10/5 → 5개 생성
+2. 매일 반복, 10/1~10/10 → 10개 생성, 10/11은 없음
+3. 매주 반복, 10/1~10/31 → 5개 생성
+4. 매월 반복, 10/1~12/31 → 3개 생성
+5. 연간 반복, 10/1~2025-12-31 → 1개 생성
+6. 종료 날짜 = 시작 날짜 → 1개 생성
+7. 종료 날짜가 첫 반복 전 → 1개 생성 (시작일만)
+
+---
+
+## ❌ 유닛 테스트 후보에서 제외된 항목
+
+### 1. UI 표시 로직
+- TC-3-1-1 (종료 날짜 필드 표시/숨김)
+- TC-3-3-3 (일정 목록에서 종료 날짜 표시)
+- **제외 이유**: UI 렌더링 로직은 통합 테스트로만 검증
+
+### 2. 이벤트 수정 로직
+- TC-3-3-1, TC-3-3-2 (종료 날짜 수정)
+- **제외 이유**: API 호출과 상태 관리가 포함되어 통합 테스트로 충분
+
+### 3. API 호출 로직
+- MSW를 사용한 POST /api/events-list 호출
+- **제외 이유**: useEventOperations 훅 내부 로직, 통합 테스트로 충분
+
+---
+
+## 📊 요약
+
+| 후보 함수 | 파일 위치 (예상) | 테스트 케이스 수 | 우선순위 |
+|-----------|------------------|------------------|----------|
+| `validateRepeatEndDate` | `src/utils/repeatValidation.ts` | 6 | High |
+| `getRepeatEndDate` | `src/utils/repeatDateUtils.ts` | 8 | High |
+| `generateRecurringEventsUntilEndDate` | `src/utils/repeatScheduler.ts` | 7 | Medium |
+
+**총 3개 함수, 21개 유닛 테스트 케이스**
+
+---
+
+## 🎯 다음 단계: Stage 5 (Unit Test Design)
+
+유닛 테스트 후보 3개에 대한 상세한 테스트 설계를 작성합니다:
+1. `validateRepeatEndDate` → 날짜 검증 로직
+2. `getRepeatEndDate` → 기본값 및 최대값 처리
+3. `generateRecurringEventsUntilEndDate` → 종료 날짜까지 반복 생성
+
+---
+
+**작성일**: 2025-10-30
+**유닛 테스트 후보**: 3개
+**예상 테스트 케이스**: 21개
+
diff --git a/.cursor/outputs/4-integration-to-unit/feature4-breakdown-test-design.md b/.cursor/outputs/4-integration-to-unit/feature4-breakdown-test-design.md
new file mode 100644
index 00000000..37f3f50a
--- /dev/null
+++ b/.cursor/outputs/4-integration-to-unit/feature4-breakdown-test-design.md
@@ -0,0 +1,128 @@
+# Feature 4: 반복 일정 수정 - Unit Test Candidates
+
+## 분석 대상
+
+- Integration Test: `src/__tests__/integration/feature4-integration.spec.tsx`
+- Test Design: `.cursor/outputs/3-integration-test-design/feature4-test-design.md`
+
+---
+
+## 유닛 테스트 후보 식별 결과
+
+### ✅ 후보 1: 반복 그룹 식별 함수 (`findRepeatGroup`)
+
+**식별 근거:**
+
+- TC-4-2-1, TC-4-2-3에서 "같은 반복 그룹의 모든 일정" 검증
+- 이는 순수한 배열 필터링/검색 로직으로, 유닛 테스트로 분리 가능
+
+**함수 시그니처 (예상):**
+
+```typescript
+function findRepeatGroup(events: Event[], targetEvent: Event): Event[];
+```
+
+**유닛 테스트 시나리오:**
+
+1. 같은 제목, 시간, 반복 유형의 이벤트들 → 모든 그룹 멤버 반환
+2. 유일한 반복 일정 → 자기 자신만 반환 (배열 길이 1)
+3. 제목이 같지만 시간이 다른 이벤트 → 제외
+4. 제목과 시간이 같지만 반복 유형이 다른 이벤트 → 제외
+5. 일반 일정 (repeat.type = 'none') → 빈 배열 또는 자기 자신만
+6. 빈 배열 입력 → 빈 배열 반환
+7. 존재하지 않는 이벤트 → 빈 배열 반환
+
+---
+
+### ✅ 후보 2: 단일/전체 수정 적용 함수 (`applyEventUpdate`)
+
+**식별 근거:**
+
+- TC-4-1-1에서 "단일 수정 시 repeat.type = 'none'" 처리
+- TC-4-2-1에서 "전체 수정 시 repeat.type 유지" 처리
+- 이는 순수한 객체 변환 로직
+
+**함수 시그니처 (예상):**
+
+```typescript
+function applyEventUpdate(event: Event, updates: Partial, mode: 'single' | 'all'): Event;
+```
+
+**유닛 테스트 시나리오:**
+
+1. mode = 'single', 제목 수정 → repeat.type = 'none'
+2. mode = 'all', 제목 수정 → repeat.type 유지
+3. mode = 'single', 여러 필드 수정 → repeat.type = 'none'
+4. mode = 'all', 시간 수정 → repeat.type 유지
+5. 일반 일정 (repeat.type = 'none') → mode 무관하게 'none' 유지
+6. updates가 빈 객체 → 원본 이벤트 반환 (수정 없음)
+7. mode가 undefined → 기본값 'single' 처리 (또는 에러)
+
+---
+
+### ✅ 후보 3: 반복 일정 여부 확인 함수 (`isRepeatingEvent`)
+
+**식별 근거:**
+
+- TC-4-3-1, TC-4-3-4에서 "반복 일정인지 확인하여 다이얼로그 표시 여부 결정"
+- 이미 Feature 2에서 구현되었을 가능성 있음 (재사용)
+
+**함수 시그니처 (기존):**
+
+```typescript
+function isRepeatingEvent(event: Event | EventForm | null | undefined): boolean;
+```
+
+**추가 유닛 테스트 시나리오 (Feature 4 관점):**
+
+1. repeat.type = 'daily' → true
+2. repeat.type = 'none' → false
+3. repeat.type = undefined → false (방어 코드)
+
+---
+
+## ❌ 유닛 테스트 후보에서 제외된 항목
+
+### 1. 다이얼로그 UI 로직
+
+- TC-4-3-1, TC-4-3-2, TC-4-3-3 (다이얼로그 표시, 버튼 클릭)
+- **제외 이유**: UI 렌더링 로직은 통합 테스트로만 검증
+
+### 2. 이벤트 수정 API 호출
+
+- TC-4-1-1, TC-4-2-1 (PUT /api/events/{id} 호출)
+- **제외 이유**: useEventOperations 훅 내부 로직, 통합 테스트로 충분
+
+### 3. 아이콘 표시/숨김 로직
+
+- TC-4-1-2, TC-4-2-2 (반복 아이콘 UI 변경)
+- **제외 이유**: UI 렌더링 로직, Feature 2와 중복
+
+---
+
+## 📊 요약
+
+| 후보 함수 | 파일 위치 (예상) | 테스트 케이스 수 | 우선순위 |
+| ------------------ | -------------------------------------- | ---------------- | --------------- |
+| `findRepeatGroup` | `src/utils/repeatGroupUtils.ts` | 7 | High |
+| `applyEventUpdate` | `src/utils/eventUpdateUtils.ts` | 7 | High |
+| `isRepeatingEvent` | `src/utils/eventTypeChecker.ts` (기존) | 3 (추가) | Low (이미 구현) |
+
+**총 2개 신규 함수, 17개 유닛 테스트 케이스 (isRepeatingEvent 제외 시 14개)**
+
+---
+
+## 🎯 다음 단계: Stage 5 (Unit Test Design)
+
+유닛 테스트 후보 2개에 대한 상세한 테스트 설계를 작성합니다:
+
+1. `findRepeatGroup` → 반복 그룹 식별
+2. `applyEventUpdate` → 단일/전체 수정 적용
+
+(`isRepeatingEvent`는 이미 Feature 2에서 구현되었으므로 추가 테스트 불필요)
+
+---
+
+**작성일**: 2025-10-30
+**유닛 테스트 후보**: 2개 (신규)
+**예상 테스트 케이스**: 14개
diff --git a/.cursor/outputs/4-integration-to-unit/feature5-breakdown-test-design.md b/.cursor/outputs/4-integration-to-unit/feature5-breakdown-test-design.md
new file mode 100644
index 00000000..59e27b2f
--- /dev/null
+++ b/.cursor/outputs/4-integration-to-unit/feature5-breakdown-test-design.md
@@ -0,0 +1,90 @@
+# Feature 5: 반복 일정 삭제 - Unit Test Candidates
+
+## 분석 대상
+
+- Integration Test: `src/__tests__/integration/feature5-integration.spec.tsx`
+- Test Design: `.cursor/outputs/3-integration-test-design/feature5-test-design.md`
+
+---
+
+## 유닛 테스트 후보 식별 결과
+
+### ✅ 후보 1: 반복 그룹 식별 함수 (`findRepeatGroup`)
+
+**식별 근거:**
+
+- TC-5-3-1에서 "동일한 반복 그룹의 모든 일정 삭제" 검증
+- Feature 4에서 이미 구현 및 테스트됨 (재사용)
+- 순수한 배열 필터링/검색 로직으로, 유닛 테스트로 분리 가능
+
+**함수 시그니처 (기존):**
+
+```typescript
+function findRepeatGroup(events: Event[], targetEvent: Event): Event[];
+```
+
+**Feature 5 관점에서의 추가 검증 필요성:**
+
+- Feature 4에서 이미 유닛 테스트 구현 완료
+- Feature 5에서는 이 함수를 삭제 로직에서 재사용
+- **결론**: 추가 유닛 테스트 불필요 (Feature 4에서 이미 검증됨)
+
+---
+
+## ❌ 유닛 테스트 후보에서 제외된 항목
+
+### 1. 다이얼로그 UI 로직
+
+- TC-5-1-1, TC-5-1-2 (다이얼로그 표시, 버튼 클릭)
+- **제외 이유**: UI 렌더링 로직은 통합 테스트로만 검증
+
+### 2. 삭제 API 호출
+
+- TC-5-2-1, TC-5-3-1 (DELETE /api/events/{id} 호출)
+- **제외 이유**: useEventOperations 훅 내부 로직, 통합 테스트로 충분
+
+### 3. 단일/전체 삭제 분기 처리
+
+- TC-5-2-1 ("예" 선택 시 단일 삭제), TC-5-3-1 ("아니오" 선택 시 전체 삭제)
+- **제외 이유**: React 컴포넌트 내부 상태 관리 및 이벤트 핸들러 로직
+
+### 4. 반복 일정 여부 확인
+
+- TC-5-1-1, TC-5-1-2 (반복 일정인지 확인하여 다이얼로그 표시 여부 결정)
+- **제외 이유**: Feature 2에서 이미 구현 및 테스트됨 (`isRepeatingEvent`)
+
+---
+
+## 📊 요약
+
+| 후보 함수 | 파일 위치 | 테스트 케이스 수 | 우선순위 |
+| ----------------- | -------------------------------------- | ---------------- | -------- |
+| `findRepeatGroup` | `src/utils/repeatGroupUtils.ts` (기존) | 0 (재사용) | N/A |
+
+**총 0개 신규 함수, 0개 추가 유닛 테스트 케이스**
+
+**이유**: Feature 5는 주로 UI 상호작용과 API 호출 로직으로 구성되어 있으며, 순수 함수 로직은 이미 Feature 4에서 구현 및 테스트된 `findRepeatGroup`을 재사용합니다.
+
+---
+
+## 🎯 다음 단계: Stage 5 (Unit Test Design)
+
+**결론**: Feature 5는 유닛 테스트 설계가 불필요합니다.
+
+이유:
+
+1. 삭제 로직은 주로 React 컴포넌트와 훅에서 처리되며, 순수 함수가 아닙니다.
+2. `findRepeatGroup`은 Feature 4에서 이미 구현 및 테스트되었습니다.
+3. 나머지 로직(다이얼로그 표시, API 호출 등)은 통합 테스트로 충분히 검증 가능합니다.
+
+**대안**:
+
+- 통합 테스트로 모든 삭제 시나리오를 충분히 커버하고 있음 (TC-5-1-1 ~ TC-5-3-3, 총 8개 테스트 케이스)
+- 추가 유닛 테스트는 코드 복잡도를 높일 뿐 실익이 없음
+
+---
+
+**작성일**: 2025-10-31
+**유닛 테스트 후보**: 0개 (신규)
+**예상 테스트 케이스**: 0개
+**권장사항**: 통합 테스트로 충분히 검증됨
diff --git a/.cursor/outputs/5-unit-test-design/unit-test-design-feature1.md b/.cursor/outputs/5-unit-test-design/unit-test-design-feature1.md
new file mode 100644
index 00000000..6f65e308
--- /dev/null
+++ b/.cursor/outputs/5-unit-test-design/unit-test-design-feature1.md
@@ -0,0 +1,33 @@
+# Unit Candidates for "FEATURE1: 반복 유형 선택"
+
+## 1. RepeatScheduler (Utility)
+
+- **Responsibilities**: 반복 유형에 따라 저장될 일정 인스턴스 목록을 생성하고 예외 상황(31일, 윤년)을 처리한다.
+- **Methods / Interfaces**:
+ - `generateDailyOccurrences(params: RecurringGenerationParams): EventForm[]` – 요청된 횟수만큼 연속된 날짜를 생성한다.
+ - `generateWeeklyOccurrences(params: RecurringGenerationParams): EventForm[]` – 동일한 요일로 주간 반복 일정을 생성한다.
+ - `generateMonthlyOccurrences(params: RecurringGenerationParams): EventForm[]` – 월 반복 시 대상 일이 없는 월을 건너뛴다.
+ - `generateYearlyOccurrences(params: RecurringGenerationParams): EventForm[]` – 윤년 2월 29일 예외를 처리하며 연간 반복을 생성한다.
+ - `generateSingleEvent(baseEvent: EventForm): EventForm[]` – 반복 미선택 시 단일 일정만 반환한다.
+- **Relations**: 날짜 검증은 `RepeatDateUtils`에 의존하며, 상위 컴포넌트나 훅에서 호출되어 반복 일정 배열을 생성한다.
+
+## 2. RepeatDateUtils (Utility)
+
+- **Responsibilities**: 반복 일정 계산에 필요한 날짜 유효성 검사와 윤년 판별을 수행한다.
+- **Methods / Interfaces**:
+ - `isLeapYear(year: number): boolean` – 윤년 여부를 판별한다.
+ - `getLastDayOfMonth(year: number, month: number): number` – 해당 월의 마지막 날짜를 반환한다.
+ - `isValidDateInMonth(year: number, month: number, day: number): boolean` – 지정한 날짜가 유효한지 확인한다.
+ - `addDays(date: string, delta: number): string` – ISO 날짜 문자열에 일 단위 증분을 적용한다.
+ - `addWeeks(date: string, delta: number): string` – ISO 날짜 문자열에 주 단위 증분을 적용한다.
+ - `addMonths(date: string, delta: number): string` – ISO 날짜 문자열에 월 단위 증분을 적용한다.
+ - `addYears(date: string, delta: number): string` – ISO 날짜 문자열에 연 단위 증분을 적용한다.
+- **Relations**: `RepeatScheduler`의 모든 생성 함수에서 사용되며, 다른 기능(예: 반복 종료 계산)에서도 재사용 가능하다.
+
+## 3. RepeatOptionsProvider (Utility)
+
+- **Responsibilities**: UI에 노출할 반복 옵션 목록을 제공한다.
+- **Methods / Interfaces**:
+ - `getRepeatOptions(): RepeatType[]` – 반복 유형 옵션 배열(daily, weekly, monthly, yearly)을 반환한다.
+ - `getRepeatOptionLabel(type: RepeatType): string` – 각 반복 유형에 대응하는 한글 레이블을 반환한다.
+- **Relations**: UI 컴포넌트나 훅에서 드롭다운 옵션을 구성할 때 사용된다.
diff --git a/.cursor/outputs/5-unit-test-design/unit-test-design-feature2.md b/.cursor/outputs/5-unit-test-design/unit-test-design-feature2.md
new file mode 100644
index 00000000..c6cde13e
--- /dev/null
+++ b/.cursor/outputs/5-unit-test-design/unit-test-design-feature2.md
@@ -0,0 +1,281 @@
+# 🧪 단위 테스트 설계서 - FEATURE2: 반복 일정 표시
+
+**기능 개요**: 캘린더 뷰와 일정 목록에서 반복 일정을 아이콘으로 시각적으로 구분
+
+**참조 문서**:
+
+- 통합 테스트 설계: `feature2-test-design.md`
+- 통합 테스트 코드: `feature2-integration.spec.tsx`
+- 단위 후보 분석: `feature2-breakdown-test-design.md`
+
+---
+
+## 1. 테스트 목적
+
+**핵심 판별 로직의 정확성 보장**
+
+- 이벤트가 반복 일정인지 일반 일정인지 정확히 판별
+- 다양한 반복 유형(daily, weekly, monthly, yearly, none)을 올바르게 구분
+- 엣지 케이스(null, undefined, 불완전한 객체)를 안전하게 처리
+
+**재사용성 및 안정성 확보**
+
+- 여러 UI 컴포넌트에서 재사용 가능한 순수 함수 제공
+- 통합 테스트와 이중 검증으로 안정성 극대화
+- 리팩터링 시 안전망 역할
+
+---
+
+## 2. 테스트 범위
+
+### 포함
+
+- `isRepeatingEvent` 함수의 모든 반복 유형 판별
+- null/undefined 입력 시 안전한 처리
+- 불완전한 객체(repeat 없음, repeat.type 없음) 처리
+- Event 타입과 EventForm 타입 모두 지원
+
+### 제외
+
+- React 컴포넌트 렌더링 (통합 테스트 영역)
+- UI 아이콘 표시 로직 (통합 테스트 영역)
+- 배열 필터링 등 단순 조합 함수 (불필요)
+- 타입 정의 검증 (TypeScript 컴파일러 영역)
+
+---
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | ---------------------------------------- |
+| 단위 테스트 | `isRepeatingEvent` 순수 함수 입출력 검증 |
+
+---
+
+## 4. 테스트 시나리오
+
+### 대상 함수: `isRepeatingEvent`
+
+| 시나리오 ID | 설명 | 입력 | 기대 결과 | 테스트 유형 |
+| ----------- | ------------------------------- | --------------------------------- | --------- | ----------- |
+| TC-U2-1-1 | daily 반복 일정을 true로 판별 | `{ repeat: { type: 'daily' } }` | `true` | 단위 |
+| TC-U2-1-2 | weekly 반복 일정을 true로 판별 | `{ repeat: { type: 'weekly' } }` | `true` | 단위 |
+| TC-U2-1-3 | monthly 반복 일정을 true로 판별 | `{ repeat: { type: 'monthly' } }` | `true` | 단위 |
+| TC-U2-1-4 | yearly 반복 일정을 true로 판별 | `{ repeat: { type: 'yearly' } }` | `true` | 단위 |
+| TC-U2-1-5 | 일반 일정(none)을 false로 판별 | `{ repeat: { type: 'none' } }` | `false` | 단위 |
+| TC-U2-1-6 | repeat 속성 없을 때 false 반환 | `{}` | `false` | 단위 (엣지) |
+| TC-U2-1-7 | repeat.type 없을 때 false 반환 | `{ repeat: {} }` | `false` | 단위 (엣지) |
+| TC-U2-1-8 | null 입력 시 false 반환 | `null` | `false` | 단위 (엣지) |
+| TC-U2-1-9 | undefined 입력 시 false 반환 | `undefined` | `false` | 단위 (엣지) |
+
+---
+
+## 5. 구현 명세
+
+### 함수 시그니처
+
+```typescript
+/**
+ * 이벤트가 반복 일정인지 확인
+ *
+ * @param event - 검증할 이벤트 객체 (null/undefined 안전)
+ * @returns 반복 일정이면 true, 일반 일정(none) 또는 유효하지 않으면 false
+ *
+ * @example
+ * isRepeatingEvent({ repeat: { type: 'daily' } }) // true
+ * isRepeatingEvent({ repeat: { type: 'none' } }) // false
+ * isRepeatingEvent({}) // false
+ * isRepeatingEvent(null) // false
+ */
+export function isRepeatingEvent(event: Event | EventForm | null | undefined): boolean;
+```
+
+### 구현 위치
+
+- **파일**: `src/utils/eventTypeChecker.ts`
+- **테스트**: `src/__tests__/unit/eventTypeChecker.spec.ts`
+
+### 의존성
+
+- **없음** (완전히 독립적인 순수 함수)
+
+---
+
+## 6. 테스트 데이터
+
+### 정상 케이스
+
+```typescript
+const dailyEvent = {
+ id: '1',
+ title: '매일 회의',
+ repeat: { type: 'daily', interval: 1 },
+};
+
+const weeklyEvent = {
+ id: '2',
+ title: '매주 회의',
+ repeat: { type: 'weekly', interval: 1 },
+};
+
+const monthlyEvent = {
+ id: '3',
+ title: '매월 보고',
+ repeat: { type: 'monthly', interval: 1 },
+};
+
+const yearlyEvent = {
+ id: '4',
+ title: '연간 평가',
+ repeat: { type: 'yearly', interval: 1 },
+};
+
+const normalEvent = {
+ id: '5',
+ title: '일반 회의',
+ repeat: { type: 'none', interval: 1 },
+};
+```
+
+### 엣지 케이스
+
+```typescript
+const noRepeatEvent = {
+ id: '6',
+ title: '속성 없음',
+ // repeat 속성 없음
+};
+
+const incompleteRepeatEvent = {
+ id: '7',
+ title: '불완전한 객체',
+ repeat: {}, // type 없음
+};
+
+const nullEvent = null;
+const undefinedEvent = undefined;
+```
+
+---
+
+## 7. 검증 기준 (Assertion Points)
+
+### 정확성 검증
+
+- [x] 모든 반복 유형(daily, weekly, monthly, yearly)을 `true`로 판별
+- [x] 일반 일정(none)을 `false`로 판별
+- [x] 불완전한 객체를 `false`로 안전하게 처리
+
+### 안전성 검증
+
+- [x] null 입력 시 에러 없이 `false` 반환
+- [x] undefined 입력 시 에러 없이 `false` 반환
+- [x] repeat 속성 누락 시 `false` 반환
+- [x] repeat.type 속성 누락 시 `false` 반환
+
+### 타입 안전성
+
+- [x] Event 타입 지원
+- [x] EventForm 타입 지원
+- [x] null/undefined 유니온 타입 지원
+
+---
+
+## 8. 비고
+
+### Feature2의 특수성
+
+- UI 중심 기능으로 단위 테스트 범위가 제한적
+- 통합 테스트 93점(Excellent)으로 충분히 검증됨
+- 단위 테스트는 **핵심 판별 로직 1개만 추출**하여 재사용성 확보
+
+### 단위 테스트의 가치
+
+1. **재사용성**: 여러 컴포넌트에서 일관된 판별 로직 사용
+2. **이중 검증**: 통합 테스트(UI 결과) + 단위 테스트(로직 정확성)
+3. **엣지 케이스**: 통합 테스트에서 다루기 어려운 null/undefined 검증
+4. **리팩터링 안전망**: UI 변경 시에도 로직 정확성 보장
+
+### 구현 우선순위
+
+- **높음**: `isRepeatingEvent` (권장, 비용 낮음, 효과 높음)
+- **낮음**: 추가 Utility 함수 (현재는 불필요, 필요 시 점진적 추가)
+
+---
+
+## 9. 테스트 코드 작성 원칙
+
+이 테스트는 다음 원칙을 따릅니다:
+
+### DAMP (Descriptive and Meaningful Phrases)
+
+- 각 테스트 케이스는 독립적이고 명확한 이름
+- 중복을 허용하더라도 의도가 명확히 드러나도록 작성
+
+### 결과 검증, 구현 검증 금지
+
+- 입력 → 출력만 검증 (black box)
+- 내부 구현 방식은 테스트하지 않음
+
+### 읽기 좋은 테스트
+
+- AAA 패턴 (Arrange-Act-Assert) 명확히 구분
+- 테스트 이름만 봐도 무엇을 검증하는지 이해 가능
+
+### 비즈니스 행위 중심
+
+- "반복 일정 판별"이라는 비즈니스 의도 명확히 표현
+- 기술적 세부사항보다 도메인 언어 사용
+
+---
+
+## 10. 예상 테스트 코드 구조
+
+```typescript
+// src/__tests__/unit/eventTypeChecker.spec.ts
+import { describe, it, expect } from 'vitest';
+import { isRepeatingEvent } from '../../utils/eventTypeChecker';
+
+describe('isRepeatingEvent', () => {
+ describe('정상 케이스: 반복 유형 판별', () => {
+ it('TC-U2-1-1: daily 반복 일정을 true로 판별', () => {
+ // Arrange
+ const event = { repeat: { type: 'daily', interval: 1 } };
+
+ // Act
+ const result = isRepeatingEvent(event as any);
+
+ // Assert
+ expect(result).toBe(true);
+ });
+
+ // ... TC-U2-1-2 ~ TC-U2-1-4 (weekly, monthly, yearly)
+
+ it('TC-U2-1-5: 일반 일정(none)을 false로 판별', () => {
+ const event = { repeat: { type: 'none', interval: 1 } };
+ expect(isRepeatingEvent(event as any)).toBe(false);
+ });
+ });
+
+ describe('엣지 케이스: 안전한 처리', () => {
+ it('TC-U2-1-6: repeat 속성 없을 때 false 반환', () => {
+ const event = {};
+ expect(isRepeatingEvent(event as any)).toBe(false);
+ });
+
+ // ... TC-U2-1-7 ~ TC-U2-1-9 (불완전한 객체, null, undefined)
+ });
+});
+```
+
+---
+
+**작성 기준**:
+
+- `/checklists/unit-test.md`
+- Kent Beck의 TDD 원칙
+- DAMP 원칙
+
+**작성일**: 2025-10-30
+**통합 테스트 품질**: 93/100 (Excellent)
+**단위 테스트 범위**: 최소 (핵심 로직 1개)
diff --git a/.cursor/outputs/5-unit-test-design/unit-test-design-feature3.md b/.cursor/outputs/5-unit-test-design/unit-test-design-feature3.md
new file mode 100644
index 00000000..7b992335
--- /dev/null
+++ b/.cursor/outputs/5-unit-test-design/unit-test-design-feature3.md
@@ -0,0 +1,227 @@
+# 🧪 유닛 테스트 설계서: Feature 3 - 반복 일정 종료 조건
+
+## 1. 테스트 목적
+
+반복 일정의 종료 날짜 관련 순수 함수들이 올바르게 동작하는지 검증합니다.
+- 종료 날짜 검증 로직의 정확성
+- 기본값 및 최대값 처리 로직의 정확성
+- 종료 날짜까지 반복 일정 생성 로직의 정확성
+
+## 2. 테스트 범위
+
+### 포함
+- `validateRepeatEndDate`: 종료 날짜 검증 함수
+- `getRepeatEndDate`: 기본값/최대값 적용 함수
+- `generateRecurringEventsUntilEndDate`: 종료 날짜까지 생성 함수
+
+### 제외
+- UI 렌더링 로직
+- API 호출 로직
+- React 컴포넌트 및 훅
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | ----------------------------------- |
+| 단위 테스트 | 순수 함수의 입력-출력 검증 |
+| 통합 테스트 | (제외) UI 및 API 통합은 별도 검증 |
+
+---
+
+## 4. 테스트 시나리오
+
+### 함수 1: `validateRepeatEndDate`
+
+**위치**: `src/utils/repeatValidation.ts` (신규 파일)
+
+**함수 시그니처**:
+```typescript
+function validateRepeatEndDate(
+ startDate: string,
+ endDate: string | undefined
+): { valid: boolean; error?: string }
+```
+
+**테스트 케이스**:
+
+| TC ID | 설명 | 입력 | 기대 결과 | 테스트 유형 |
+|-------|------|------|-----------|-------------|
+| TC-V-1 | 종료 날짜가 시작 날짜보다 이전이면 invalid | startDate: "2025-10-15" endDate: "2025-10-10" | `{ valid: false, error: "종료 날짜는 시작 날짜 이후여야 합니다" }` | 단위 |
+| TC-V-2 | 종료 날짜가 시작 날짜와 같으면 valid | startDate: "2025-10-15" endDate: "2025-10-15" | `{ valid: true }` | 단위 |
+| TC-V-3 | 종료 날짜가 시작 날짜보다 이후면 valid | startDate: "2025-10-15" endDate: "2025-10-20" | `{ valid: true }` | 단위 |
+| TC-V-4 | 종료 날짜가 undefined면 valid (기본값 적용) | startDate: "2025-10-15" endDate: undefined | `{ valid: true }` | 단위 |
+| TC-V-5 | 종료 날짜가 빈 문자열이면 valid (기본값 적용) | startDate: "2025-10-15" endDate: "" | `{ valid: true }` | 단위 |
+| TC-V-6 | 잘못된 날짜 형식이면 invalid | startDate: "2025-10-15" endDate: "invalid-date" | `{ valid: false, error: "올바른 날짜 형식이 아닙니다" }` | 단위 |
+
+---
+
+### 함수 2: `getRepeatEndDate`
+
+**위치**: `src/utils/repeatDateUtils.ts`
+
+**함수 시그니처**:
+```typescript
+function getRepeatEndDate(
+ endDate: string | undefined
+): string
+```
+
+**테스트 케이스**:
+
+| TC ID | 설명 | 입력 | 기대 결과 | 테스트 유형 |
+|-------|------|------|-----------|-------------|
+| TC-G-1 | undefined면 기본값 반환 | endDate: undefined | `"2025-12-31"` | 단위 |
+| TC-G-2 | null이면 기본값 반환 | endDate: null | `"2025-12-31"` | 단위 |
+| TC-G-3 | 빈 문자열이면 기본값 반환 | endDate: "" | `"2025-12-31"` | 단위 |
+| TC-G-4 | 유효한 날짜면 그대로 반환 | endDate: "2025-10-31" | `"2025-10-31"` | 단위 |
+| TC-G-5 | 최대값(2025-12-31)이면 그대로 반환 | endDate: "2025-12-31" | `"2025-12-31"` | 단위 |
+| TC-G-6 | 최대값 초과하면 최대값 반환 | endDate: "2026-01-01" | `"2025-12-31"` | 단위 |
+| TC-G-7 | 훨씬 미래 날짜도 최대값으로 제한 | endDate: "2027-12-31" | `"2025-12-31"` | 단위 |
+| TC-G-8 | 과거 날짜는 그대로 반환 (시작 날짜 검증은 별도) | endDate: "2024-12-31" | `"2024-12-31"` | 단위 |
+
+---
+
+### 함수 3: `generateRecurringEventsUntilEndDate`
+
+**위치**: `src/utils/repeatScheduler.ts` (기존 파일 확장)
+
+**함수 시그니처**:
+```typescript
+function generateRecurringEventsUntilEndDate(
+ baseEvent: Event | EventForm,
+ endDate: string
+): Event[]
+```
+
+**테스트 케이스**:
+
+| TC ID | 설명 | 입력 | 기대 결과 | 테스트 유형 |
+|-------|------|------|-----------|-------------|
+| TC-R-1 | 매일 반복, 5일간 | startDate: "2025-10-01" repeat: daily endDate: "2025-10-05" | 5개 이벤트 생성 날짜: 10/1~10/5 | 단위 |
+| TC-R-2 | 매일 반복, 10일간 | startDate: "2025-10-01" repeat: daily endDate: "2025-10-10" | 10개 이벤트 마지막: 10/10 | 단위 |
+| TC-R-3 | 매주 반복, 1개월 | startDate: "2025-10-01" repeat: weekly endDate: "2025-10-31" | 5개 이벤트 (10/1, 10/8, 10/15, 10/22, 10/29) | 단위 |
+| TC-R-4 | 매월 반복, 3개월 | startDate: "2025-10-01" repeat: monthly endDate: "2025-12-31" | 3개 이벤트 (10/1, 11/1, 12/1) | 단위 |
+| TC-R-5 | 연간 반복, 같은 해 | startDate: "2025-10-01" repeat: yearly endDate: "2025-12-31" | 1개 이벤트 (10/1만) | 단위 |
+| TC-R-6 | 종료 날짜 = 시작 날짜 | startDate: "2025-10-15" repeat: daily endDate: "2025-10-15" | 1개 이벤트 (10/15만) | 단위 |
+| TC-R-7 | 종료 날짜가 첫 반복 전 (경계값) | startDate: "2025-10-01" repeat: weekly endDate: "2025-10-02" | 1개 이벤트 (10/1만) | 단위 |
+
+---
+
+## 5. 테스트 데이터
+
+### Mock Event for Testing
+```typescript
+const baseEvent: EventForm = {
+ title: '테스트 이벤트',
+ date: '2025-10-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: 'Unit test',
+ location: 'Test',
+ category: '테스트',
+ repeat: {
+ type: 'daily', // 테스트마다 변경
+ interval: 1,
+ },
+ notificationTime: 10,
+};
+```
+
+## 6. 검증 기준 (Assertion Points)
+
+### `validateRepeatEndDate`
+- [ ] 날짜 비교 로직이 정확함
+- [ ] 에러 메시지가 명확함
+- [ ] undefined/null/빈 문자열을 올바르게 처리
+- [ ] 잘못된 형식 감지
+
+### `getRepeatEndDate`
+- [ ] 기본값(2025-12-31)이 올바르게 적용됨
+- [ ] 최대값 제한이 올바르게 동작
+- [ ] 유효한 날짜는 변경되지 않음
+- [ ] null safety (undefined, null, "" 처리)
+
+### `generateRecurringEventsUntilEndDate`
+- [ ] 생성된 이벤트 개수가 정확함
+- [ ] 마지막 이벤트 날짜가 endDate 이하
+- [ ] endDate + 1일 이벤트는 생성되지 않음
+- [ ] 각 반복 유형(daily, weekly, monthly, yearly)이 올바르게 동작
+- [ ] 경계값 처리가 정확함
+
+## 7. 엣지 케이스 및 경계값
+
+### `validateRepeatEndDate`
+| 케이스 | 입력 | 기대 동작 |
+|--------|------|-----------|
+| 종료 = 시작 | start: "2025-10-15", end: "2025-10-15" | valid: true |
+| 종료 = 시작 - 1일 | start: "2025-10-15", end: "2025-10-14" | valid: false |
+| 윤년 날짜 | start: "2024-02-28", end: "2024-02-29" | valid: true |
+| 12월 31일 | start: "2025-12-31", end: "2025-12-31" | valid: true |
+
+### `getRepeatEndDate`
+| 케이스 | 입력 | 기대 동작 |
+|--------|------|-----------|
+| 정확히 최대값 | "2025-12-31" | "2025-12-31" |
+| 최대값 + 1일 | "2026-01-01" | "2025-12-31" |
+| 최대값 - 1일 | "2025-12-30" | "2025-12-30" |
+| 과거 날짜 | "2024-01-01" | "2024-01-01" (시작 날짜 검증은 별도) |
+
+### `generateRecurringEventsUntilEndDate`
+| 케이스 | 입력 | 기대 동작 |
+|--------|------|-----------|
+| 종료 = 시작 | start: "2025-10-15", end: "2025-10-15", daily | 1개 |
+| 종료 = 시작 + 1일 | start: "2025-10-15", end: "2025-10-16", daily | 2개 |
+| 월말 → 다음 달 | start: "2025-10-31", end: "2025-11-30", monthly | 2개 (10/31, 11/30) |
+| 윤년 2월 | start: "2024-02-29", end: "2024-03-31", monthly | 2개 (2/29, 3/29) |
+
+## 8. 비고
+
+### 구현 시 고려사항
+- 날짜 비교는 `new Date().getTime()` 사용
+- 최대값(2025-12-31)은 상수로 정의 (`const MAX_REPEAT_END_DATE = '2025-12-31'`)
+- 함수는 순수 함수로 구현 (side effect 없음)
+- 타임존 이슈 방지: UTC 또는 로컬 날짜 일관성 유지
+
+### 테스트 코드 구조
+```typescript
+describe('repeatValidation', () => {
+ describe('validateRepeatEndDate', () => {
+ it('TC-V-1: 종료 날짜가 시작 날짜보다 이전이면 invalid', () => {
+ const result = validateRepeatEndDate('2025-10-15', '2025-10-10');
+ expect(result.valid).toBe(false);
+ expect(result.error).toBe('종료 날짜는 시작 날짜 이후여야 합니다');
+ });
+ // ... 더 많은 테스트
+ });
+});
+
+describe('repeatDateUtils', () => {
+ describe('getRepeatEndDate', () => {
+ it('TC-G-1: undefined면 기본값 반환', () => {
+ const result = getRepeatEndDate(undefined);
+ expect(result).toBe('2025-12-31');
+ });
+ // ... 더 많은 테스트
+ });
+});
+
+describe('repeatScheduler', () => {
+ describe('generateRecurringEventsUntilEndDate', () => {
+ it('TC-R-1: 매일 반복, 5일간', () => {
+ const events = generateRecurringEventsUntilEndDate(baseEvent, '2025-10-05');
+ expect(events).toHaveLength(5);
+ expect(events[0].date).toBe('2025-10-01');
+ expect(events[4].date).toBe('2025-10-05');
+ });
+ // ... 더 많은 테스트
+ });
+});
+```
+
+---
+
+**테스트 설계 완료일**: 2025-10-30
+**총 테스트 함수**: 3개
+**총 테스트 케이스**: 21개 (TC-V: 6, TC-G: 8, TC-R: 7)
+**테스트 유형**: 단위 테스트 100%
+
diff --git a/.cursor/outputs/5-unit-test-design/unit-test-design-feature4.md b/.cursor/outputs/5-unit-test-design/unit-test-design-feature4.md
new file mode 100644
index 00000000..f0122ae0
--- /dev/null
+++ b/.cursor/outputs/5-unit-test-design/unit-test-design-feature4.md
@@ -0,0 +1,199 @@
+# 🧪 유닛 테스트 설계서: Feature 4 - 반복 일정 수정
+
+## 1. 테스트 목적
+
+반복 일정 수정 관련 순수 함수들이 올바르게 동작하는지 검증합니다.
+
+- 반복 그룹 식별 로직의 정확성
+- 단일/전체 수정 적용 로직의 정확성
+
+## 2. 테스트 범위
+
+### 포함
+
+- `findRepeatGroup`: 반복 그룹 식별 함수
+- `applyEventUpdate`: 단일/전체 수정 적용 함수
+
+### 제외
+
+- UI 렌더링 로직
+- API 호출 로직
+- React 컴포넌트 및 훅
+- `isRepeatingEvent` (이미 Feature 2에서 구현 및 테스트)
+
+## 3. 테스트 분류
+
+| 구분 | 설명 |
+| ----------- | --------------------------------- |
+| 단위 테스트 | 순수 함수의 입력-출력 검증 |
+| 통합 테스트 | (제외) UI 및 API 통합은 별도 검증 |
+
+---
+
+## 4. 테스트 시나리오
+
+### 함수 1: `findRepeatGroup`
+
+**위치**: `src/utils/repeatGroupUtils.ts` (신규 파일)
+
+**함수 시그니처**:
+
+```typescript
+function findRepeatGroup(events: Event[], targetEvent: Event): Event[];
+```
+
+**테스트 케이스**:
+
+| TC ID | 설명 | 입력 | 기대 결과 | 테스트 유형 |
+| ------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | ----------- |
+| TC-F-1 | 같은 그룹의 모든 이벤트를 반환한다 | events: [event1, event2, event3] 모두 title="팀 미팅", startTime="10:00", repeat.type="weekly" targetEvent: event1 | `[event1, event2, event3]` | 단위 |
+| TC-F-2 | 유일한 반복 일정은 자기 자신만 반환한다 | events: [singleEvent] targetEvent: singleEvent | `[singleEvent]` | 단위 |
+| TC-F-3 | 제목이 같지만 시간이 다른 이벤트는 제외한다 | events: [event1 (10:00), event2 (11:00)] 같은 제목, 다른 시간 targetEvent: event1 | `[event1]` (event2 제외) | 단위 |
+| TC-F-4 | 제목과 시간이 같지만 반복 유형이 다른 이벤트는 제외한다 | events: [event1 (weekly), event2 (daily)] 같은 제목/시간, 다른 반복 유형 targetEvent: event1 | `[event1]` (event2 제외) | 단위 |
+| TC-F-5 | 일반 일정(repeat.type='none')은 자기 자신만 반환한다 | events: [normalEvent1, normalEvent2] 같은 제목/시간, 모두 repeat.type='none' targetEvent: normalEvent1 | `[normalEvent1]` (normalEvent2 제외) | 단위 |
+| TC-F-6 | 빈 배열 입력 시 빈 배열을 반환한다 | events: [] targetEvent: event1 | `[]` | 단위 |
+| TC-F-7 | 존재하지 않는 이벤트는 빈 배열을 반환한다 | events: [event1, event2] targetEvent: event3 (존재하지 않음) | `[]` | 단위 |
+
+---
+
+### 함수 2: `applyEventUpdate`
+
+**위치**: `src/utils/eventUpdateUtils.ts` (신규 파일)
+
+**함수 시그니처**:
+
+```typescript
+function applyEventUpdate(event: Event, updates: Partial, mode: 'single' | 'all'): Event;
+```
+
+**테스트 케이스**:
+
+| TC ID | 설명 | 입력 | 기대 결과 | 테스트 유형 |
+| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ----------- |
+| TC-A-1 | mode='single', 제목 수정 시 repeat.type='none'으로 변경된다 | event: { title="팀 미팅", repeat.type="weekly" } updates: { title="개인 미팅" } mode: 'single' | `{ title="개인 미팅", repeat.type="none" }` | 단위 |
+| TC-A-2 | mode='all', 제목 수정 시 repeat.type이 유지된다 | event: { title="팀 미팅", repeat.type="weekly" } updates: { title="헬스" } mode: 'all' | `{ title="헬스", repeat.type="weekly" }` | 단위 |
+| TC-A-3 | mode='single', 여러 필드 수정 시 repeat.type='none'으로 변경된다 | event: { title="회의", startTime="10:00", repeat.type="daily" } updates: { title="미팅", startTime="11:00" } mode: 'single' | `{ title="미팅", startTime="11:00", repeat.type="none" }` | 단위 |
+| TC-A-4 | mode='all', 시간 수정 시 repeat.type이 유지된다 | event: { startTime="09:00", repeat.type="monthly" } updates: { startTime="10:00" } mode: 'all' | `{ startTime="10:00", repeat.type="monthly" }` | 단위 |
+| TC-A-5 | 일반 일정(repeat.type='none')은 mode 무관하게 'none' 유지 | event: { title="일반", repeat.type="none" } updates: { title="수정" } mode: 'single' 또는 'all' | `{ title="수정", repeat.type="none" }` | 단위 |
+| TC-A-6 | updates가 빈 객체면 원본 이벤트 그대로 반환된다 | event: { title="회의", repeat.type="weekly" } updates: {} mode: 'single' | `{ title="회의", repeat.type="weekly" }` (mode='single'이지만 수정 없으므로 유지) | 단위 |
+| TC-A-7 | 수정되지 않은 필드는 원본 값을 유지한다 | event: { title="회의", date="2025-10-01", startTime="10:00" } updates: { title="미팅" } mode: 'all' | `{ title="미팅", date="2025-10-01", startTime="10:00" }` | 단위 |
+
+---
+
+## 5. 테스트 데이터
+
+### Mock Events for Testing
+
+```typescript
+const mockRepeatingEvent: Event = {
+ id: 'repeat-1',
+ title: '팀 미팅',
+ date: '2025-10-06',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+};
+
+const mockNormalEvent: Event = {
+ id: 'normal-1',
+ title: '일반 회의',
+ date: '2025-10-07',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '일반 일정',
+ location: '사무실',
+ category: '업무',
+ repeat: { type: 'none', interval: 1 },
+ notificationTime: 10,
+};
+
+const mockRepeatingGroup: Event[] = [
+ { ...mockRepeatingEvent, id: 'repeat-1', date: '2025-10-06' },
+ { ...mockRepeatingEvent, id: 'repeat-2', date: '2025-10-13' },
+ { ...mockRepeatingEvent, id: 'repeat-3', date: '2025-10-20' },
+];
+```
+
+## 6. 검증 기준 (Assertion Points)
+
+### `findRepeatGroup`
+
+- [ ] 같은 제목, 시간, 반복 유형의 이벤트들을 모두 반환
+- [ ] 하나라도 다르면 제외
+- [ ] 일반 일정은 그룹에 속하지 않음
+- [ ] 빈 배열 입력 → 빈 배열 반환
+- [ ] 존재하지 않는 이벤트 → 빈 배열 반환
+
+### `applyEventUpdate`
+
+- [ ] mode='single' → repeat.type='none'
+- [ ] mode='all' → repeat.type 유지
+- [ ] 일반 일정 → mode 무관하게 'none' 유지
+- [ ] 수정되지 않은 필드는 원본 값 유지
+- [ ] 빈 updates → 원본 이벤트 반환
+
+## 7. 엣지 케이스 및 경계값
+
+### `findRepeatGroup`
+
+| 케이스 | 입력 | 기대 동작 |
+| ------------------- | ------------------- | ------------------------- |
+| 그룹 크기 = 1 | 유일한 반복 일정 | 자기 자신만 반환 |
+| 그룹 크기 = 100 | 매우 큰 그룹 | 모든 100개 반환 |
+| 제목에 특수문자 | title="회의\t미팅" | 정확히 일치하는 것만 반환 |
+| startTime = endTime | 같은 시작/종료 시간 | 시작 시간으로 비교 |
+
+### `applyEventUpdate`
+
+| 케이스 | 입력 | 기대 동작 |
+| --------------------- | ----------------------------- | -------------------------------- |
+| mode = undefined | mode 생략 | 기본값 'single' 처리 (또는 에러) |
+| updates에 repeat 포함 | updates.repeat.type = 'daily' | mode에 따라 처리 |
+| updates에 id 포함 | updates.id = 'new-id' | id는 변경하지 않음 (또는 에러) |
+
+## 8. 비고
+
+### 구현 시 고려사항
+
+- **반복 그룹 식별 기준**: `title`, `startTime`, `endTime`, `repeat.type`, `repeat.interval` 모두 일치
+- **일반 일정은 그룹 없음**: `repeat.type = 'none'`인 경우 자기 자신만 그룹
+- **대소문자 구분**: 제목 비교 시 대소문자 구분 (정확한 일치)
+- **날짜는 그룹 식별에서 제외**: 같은 그룹은 날짜가 다를 수 있음 (반복이므로)
+- **mode='single'일 때 항상 repeat.type='none'**: 단일 수정은 반복 속성 제거
+
+### 테스트 코드 구조
+
+```typescript
+describe('repeatGroupUtils', () => {
+ describe('findRepeatGroup', () => {
+ it('TC-F-1: 같은 그룹의 모든 이벤트를 반환한다', () => {
+ const result = findRepeatGroup(mockRepeatingGroup, mockRepeatingGroup[0]);
+ expect(result).toHaveLength(3);
+ expect(result).toEqual(expect.arrayContaining(mockRepeatingGroup));
+ });
+ // ... 더 많은 테스트
+ });
+});
+
+describe('eventUpdateUtils', () => {
+ describe('applyEventUpdate', () => {
+ it('TC-A-1: mode="single", 제목 수정 시 repeat.type="none"으로 변경된다', () => {
+ const result = applyEventUpdate(mockRepeatingEvent, { title: '개인 미팅' }, 'single');
+ expect(result.title).toBe('개인 미팅');
+ expect(result.repeat.type).toBe('none');
+ });
+ // ... 더 많은 테스트
+ });
+});
+```
+
+---
+
+**테스트 설계 완료일**: 2025-10-30
+**총 테스트 함수**: 2개
+**총 테스트 케이스**: 14개 (TC-F: 7, TC-A: 7)
+**테스트 유형**: 단위 테스트 100%
diff --git a/report.md b/report.md
index 3f1a2112..22ceeac3 100644
--- a/report.md
+++ b/report.md
@@ -2,20 +2,507 @@
## 사용하는 도구를 선택한 이유가 있을까요? 각 도구의 특징에 대해 조사해본적이 있나요?
+**Cursor를 선택한 이유:**
+
+- 실제로는 회사에서 평소에 사용하고 있어서 익숙한 IDE
+- 코드베이스 전체를 이해하고 컨텍스트를 유지할 수 있는 에이전트 기능
+- 복잡한 리팩토링과 디버깅 작업에 강점
+- 파일 간 관계를 이해하고 일관성 있게 수정 가능
+- IDE로서 코드 수정에 편리함
+
+**Orchestrator 워크플로우를 도입한 이유:**
+
+- TDD 사이클을 체계적으로 자동화
+- 8단계 워크플로우(분해 → 설계 → 테스트 → 구현)를 명확히 정의
+- 각 단계의 검증(Validation)을 통해 품질 보장
+- 실패 시 재시도 및 피드백 메커니즘 내장
+
+**BMAD-method 조사:**
+
+- 처음으로 알게 된 개념을 찾아보았습니다
+- 각 에이전트에게 페르소나를 부여하는 방식
+- 체크리스트와 docs를 세분화하여 관리하는 방법
+- 이 개념을 차용하여 개발을 진행했습니다
+
+**특징 조사:**
+
+- 각 도구의 명령어(command)를 `.cursor/commands/` 디렉토리에 구조화하여 저장
+- BMAD-method를 참조하여 역할 기반 에이전트 분리
+- 각 에이전트의 역할과 책임 범위를 명확히 정의
+- "되묻기 규칙"과 human-in-the-loop으로 진행 과정 제어
+
+**에이전트 구성 (최종 설계):**
+
+프로덕션 사용을 목표로 했으며, 작은 단위와 명확한 입출력이 핵심이었습니다.
+
+1. **split-by-number**: 번호대로 기능을 나누는 에이전트
+2. **feature-decomposer**: 기능을 Epic → Story → Flow로 세분화
+3. **test-designer**: 통합 테스트 디자인
+4. **integration-test-writer**: 통합 테스트 작성
+5. **integration-test-evaluator**: 통합 테스트 체크리스트 기반 평가 (90점 이상만 사용)
+6. **unit-candidate-finder**: 단위테스트 후보 식별
+7. **unit-test-writer**: 단위 테스트 작성
+8. **developer**: 테스트 기반 기능 개발
+9. **debug-doctor**: 에러 발생 시 원인 분석 및 수정 전문화
+10. **refactor**: 코드 리팩토링
+
+**체계적 구조:**
+
+```
+.cursor/
+├── commands/ # 역할별 에이전트 정의
+├── outputs/ # 각 단계의 출력물 저장
+│ ├── 2-splited-features/
+│ ├── 3-integration-test-design/
+│ └── 4-integration-to-unit/
+└── checklists/ # 검증 체크리스트
+ ├── breakdown-checklist.md
+ ├── how-to-design-test.md
+ ├── how-to-test.md
+ ├── kent-beck-test.md
+ └── integration-test-quality.md
+```
+
+**발전 과정:**
+
+- **1차 시도**: 역할 기반(PO, PM, SM) → PM이 명세를 임의 추가하여 실패
+- **2차 시도**: human-in-the-loop + 되묻기 규칙 → 역할 혼재 문제
+- **최종 설계**: 도구 기반 명확한 분리 → 각 에이전트 역할 명확화
+
## 테스트를 기반으로 하는 AI를 통한 기능 개발과 없을 때의 기능개발은 차이가 있었나요?
+**테스트 기반 개발(TDD)의 핵심 차이점:**
+
+1. **명확한 목표 지향성**
+
+ - 테스트가 실패하는 상태부터 시작 → 무엇을 구현해야 하는지 명확
+ - "이 테스트를 통과시키면 기능이 완성"이라는 명확한 완료 기준
+
+2. **디버깅 효율성 극대화**
+
+ - 실패한 테스트 → 에러 메시지 → 원인 파악 → 수정 → 테스트 통과
+ - AI에게 "이 테스트가 실패하는 이유를 찾아줘"라고 요청하면 구체적인 원인 제시
+
+3. **리그레션 방지**
+
+ - 기능 추가 시 기존 테스트로 회귀 확인 자동화
+ - "모든 테스트가 통과하는가?"가 품질 기준
+
+4. **단계별 검증 가능**
+ - 통합 테스트(240개)가 모두 통과 = 전체 기능 정상 작동
+ - 단위 테스트로 세부 로직 검증 가능
+
+**AI를 통한 기능 개발의 특별함:**
+
+- AI를 통한 기능 개발은 테스트 코드를 작성하는 문법에 대한 이해보다는, "어떤 동작을 검증해야 하는지"에 집중할 수 있게 해줍니다
+- 테스트가 명세서 역할을 하며, AI가 이 명세를 바탕으로 구현 코드를 작성합니다
+- 수동 테스트 없이도 신뢰할 수 있는 코드를 얻을 수 있습니다
+
+**없을 때의 문제점:**
+
+- 구현 후 수동 테스트 → 놓친 엣지 케이스 발생
+- 리팩토링 시 기존 기능 깨짐 위험
+- AI가 생성한 코드의 정확성 검증 어려움
+
## AI의 응답을 개선하기 위해 추가했던 여러 정보(context)는 무엇인가요?
+1. **구조화된 PRD 문서**
+
+ - FEATURE 문서를 Epic → Story → Flow로 분해
+ - 각 Flow에 대응하는 테스트 케이스 설계 문서 생성
+
+2. **기존 코드베이스 맥락**
+
+ - 타입 정의, 유틸리티 함수, 기존 패턴
+ - 예: `Event`, `EventForm` 타입, `findRepeatGroup`, `applyEventUpdate` 등
+
+3. **테스트 디자인 문서**
+
+ - `.cursor/outputs/3-integration-test-design/feature{n}-test-design.md`
+ - 각 테스트 케이스의 입력/기대 결과 명시
+
+4. **실패한 테스트의 구체적 에러 로그**
+
+ - "이 테스트가 실패합니다: `expected 5 but got 0`"
+ - 스택 트레이스, 타임아웃 정보 등 포함
+
+5. **현재 상태 정보**
+
+ - "현재 6/10 테스트 통과, 4개 실패"
+ - 실패한 테스트의 공통 패턴 제시
+
+6. **워크플로우 상태**
+
+ - 현재 어떤 Stage에 있는지 (예: Stage 8/8 - Integration TDD)
+ - 이전 단계의 아티팩트 경로
+
+7. **체크리스트 기반 검증 기준**
+ - breakdown-checklist.md: 12개 항목으로 기능 분해 품질 평가
+ - integration-test-quality.md: 통합 테스트 90점 기준 명시
+ - 각 에이전트에게 해당 체크리스트를 컨텍스트로 제공
+
## 이 context를 잘 활용하게 하기 위해 했던 노력이 있나요?
+1. **체계적인 디렉토리 구조**
+
+ ```
+ .cursor/
+ ├── commands/ # 역할별 에이전트 정의
+ ├── outputs/ # 각 단계의 출력물 저장
+ │ ├── 2-splited-features/
+ │ ├── 3-integration-test-design/
+ │ └── 4-integration-to-unit/
+ └── checklists/ # 검증 체크리스트
+ ```
+
+2. **명시적인 참조(@ 파일, 파일 경로)**
+
+ - `@FEATURE3.md`, `@feature3-test-design.md` 등으로 정확한 문서 참조
+ - 파일 경로를 명시하여 컨텍스트 범위 명확화
+ - 앞선 에이전트의 출력물을 뒷 에이전트가 참고하도록 outputs에 저장
+
+3. **단계별 검증 메커니즘**
+
+ - 각 Stage마다 Validation 단계 필수
+ - 실패 시 이전 단계로 롤백하거나 재시도
+ - integration-test-evaluator로 통합 테스트 90점 이상만 사용 가능
+
+4. **에러 발생 시 전체 맥락 제공**
+
+ - 실패한 테스트 코드 전체
+ - 관련 구현 코드
+ - 에러 메시지와 스택 트레이스
+ - 예: "이 테스트가 실패합니다: ... /debug-doctor /developer"
+
+5. **역할 기반 에이전트 활용**
+
+ - `/debug-doctor`: 원인 분석 전문화 (가설 검증 워크플로우)
+ - `/developer`: 구현 전문화
+ - `/refactor`: 리팩토링 전문화
+ - 각 에이전트에게 맞는 컨텍스트만 제공
+
+6. **체크리스트 기반 검증 체계**
+ - `breakdown-checklist.md`: 기능 분해 품질 보장
+ - `how-to-design-test.md`: 테스트 설계 가이드라인
+ - `how-to-test.md`: 테스트 작성 베스트 프랙티스
+ - `integration-test-quality.md`: 통합 테스트 90점 기준
+ - 각 단계마다 체크리스트로 자동 평가
+ - 참고자료: how-to-test.md (배휘동님 github), kent-beck-test.md
+
## 생성된 여러 결과는 만족스러웠나요? AI의 응답을 어떤 기준을 갖고 '평가(evaluation)'했나요?
+**평가 기준 (우선순위 순):**
+
+1. **테스트 통과율** (최우선)
+
+ - 모든 통합 테스트 240개 통과 = 기능 완성
+ - 단위 테스트 통과 = 세부 로직 정확
+
+2. **코드 품질**
+
+ - Linter 에러 0개
+ - TypeScript 타입 안전성
+ - 중복 코드 최소화
+
+3. **기능 정확성**
+
+ - 실제 앱에서 동작 확인
+ - 엣지 케이스 처리 (예: 윤년, 31일 처리)
+
+4. **테스트 안정성**
+ - 일시적 실패(flaky test) 없음
+ - 타임아웃 설정 적절
+ - 비동기 처리 올바름
+
+**평가 방법:**
+
+- **체크리스트 기반 평가**: 통합 테스트는 90점 이상만 승인, 아니면 재작성
+- **자동 검증**: 각 단계마다 체크리스트를 통과해야 다음 단계로 진행
+- **점수 기준**: integration-test-evaluator가 체크리스트 기반으로 점수를 매기고, 90점 이하면 test-writer가 재작성하도록 설정
+
+**만족도:**
+
+- 초기: 60% (테스트 불안정, 디버깅 반복)
+- 최종: 95% (모든 테스트 통과, 안정적)
+
+**개선 과정:**
+
+- 테스트 안정화에 상당한 시간 투자 (약 30% 시간)
+- 하지만 결과적으로 리그레션 방지 및 신뢰성 확보
+
## AI에게 어떻게 질문하는것이 더 나은 결과를 얻을 수 있었나요? 시도했던 여러 경험을 알려주세요.
+**효과적인 질문 패턴:**
+
+1. **구체적인 에러 정보 제공**
+
+ ```
+ ❌ "테스트가 안 돼"
+ ✅ "TC-3-1-3이 실패합니다: expected 5 but got 0.
+ postedEvents 배열이 비어있습니다.
+ 겹침 다이얼로그가 나타나서 저장이 막히는 것 같습니다."
+ ```
+
+2. **현재 상태 + 기대 결과 명시**
+
+ ```
+ "현재 6/10 테스트 통과.
+ TC-4-1-1, TC-4-1-3이 실패합니다.
+ 원인: time validation 에러.
+ 목표: 모든 테스트 통과"
+ ```
+
+3. **관련 파일/컨텍스트 명시**
+
+ ```
+ "@feature4-integration.spec.tsx
+ @useEventOperations.ts
+ 이 두 파일을 보면서 문제를 찾아줘"
+ ```
+
+4. **역할 기반 질문**
+
+ ```
+ "/debug-doctor 근본 원인을 파악하고 수정하세요"
+ "/developer 테스트를 통과시키는 최소 구현을 작성하세요"
+ ```
+
+5. **작업 범위 명확화**
+
+ ```
+ "FEATURE3 남은 케이스 먼저 다 수정해"
+ "우선 여기까지 커밋하고, 나머지 안되는 부분 있는지 확인해보자"
+ ```
+
+6. **반복적인 피드백 제공**
+ ```
+ "1. 반복 일정 수정 기능 자체가 안돼 => 그래서 다 안되는거네"
+ "그러면 수정로직 가서 수정 로직에서 원인을 찾아봐"
+ ```
+
+**효과:**
+
+- 초기: 질문 1회 → 부분 해결 → 재질문 반복 (평균 3-4회)
+- 후기: 명확한 질문 → 전체 해결 (평균 1-2회)
+
## AI에게 지시하는 작업의 범위를 어떻게 잡았나요? 범위를 좁게, 넓게 해보고 결과를 적어주세요. 그리고 내가 생각하는 적절한 단위를 말해보세요.
+**범위 실험 결과:**
+
+1. **너무 넓은 범위** (실패 사례)
+
+ ```
+ ❌ "FEATURE3 전체 구현해줘"
+ - 결과: AI가 모든 테스트를 한 번에 작성하려 함
+ - 문제: 복잡한 상호 의존성, 부분 실패 시 롤백 어려움
+ - 시간: 예상 2시간 → 실제 6시간
+ ```
+
+2. **너무 좁은 범위** (비효율적)
+
+ ```
+ ❌ "이 한 줄 수정해줘"
+ - 결과: 전체 맥락 이해 못함
+ - 문제: 근본 원인 해결 못하고 증상만 수정
+ - 시간: 반복 작업으로 누적 시간 증가
+ ```
+
+3. **적절한 범위** (성공 사례)
+
+ ```
+ ✅ "FEATURE3 테스트 안정화 - TC-3-1-3이 실패하는데
+ postedEvents가 비어있어. 겹침 다이얼로그 문제일 것 같아"
+ - 결과: 관련된 몇 개 테스트만 함께 수정
+ - 효과: 근본 원인(초기 이벤트 겹침) 해결
+ - 시간: 예상 30분 → 실제 20분
+ ```
+
+4. **Story 단위** (가장 효과적)
+ ```
+ ✅ "Story 1: 반복 종료 조건 설정 관련 테스트 모두 통과시켜줘"
+ - 결과: 하나의 Story에 속한 4개 테스트를 함께 수정
+ - 효과: Story 단위 완성도 높음
+ - 시간: 예상 1시간 → 실제 45분
+ ```
+
+**적절한 단위:**
+
+- **Story 단위** (약 3-5개 테스트 케이스)
+ - 하나의 사용자 시나리오 완성
+ - 관련된 파일 그룹을 함께 수정
+ - 완료 기준 명확 (Story의 모든 TC 통과)
+- **실험 결과**: 범위를 좁게 잡는 것이 가장 효과적이었습니다
+ - 너무 넓으면: 복잡한 상호 의존성으로 부분 실패 시 롤백 어려움
+ - 너무 좁으면: 전체 맥락 이해 못하고 증상만 수정
+ - **좁게**: Story 단위로 점진적 개발이 가장 안정적
+
+**범위 결정 원칙:**
+
+1. **의존성 기준**: 함께 변경해야 하는 파일들을 그룹으로
+2. **실패 패턴 기준**: 같은 원인으로 실패하는 테스트들을 함께
+3. **완성도 기준**: 하나의 기능 단위가 완전히 동작하는 수준
+4. **좁게 시작**: 작은 단위부터 확실히 완성하고, 점진적으로 확장
+
## 동기들에게 공유하고 싶은 좋은 참고자료나 문구가 있었나요? 마음껏 자랑해주세요.
+**1. Orchestrator 워크플로우 설계**
+
+```
+"8단계 TDD 워크플로우로 기능 개발을 체계화했습니다.
+Breakdown → Design → Test → Implement → Refactor
+각 단계의 검증을 통해 품질을 보장합니다."
+```
+
+**2. 테스트 안정화를 위한 베스트 프랙티스**
+
+```typescript
+// ❌ 비동기 처리 없음
+await userEvent.type(input, '2025-10-05');
+expect(postedEvents).toHaveLength(5);
+
+// ✅ waitFor로 안정적 검증
+await userEvent.clear(input);
+await userEvent.type(input, '2025-10-05');
+await waitFor(() => expect(input.value).toBe('2025-10-05'));
+await waitFor(() => expect(postedEvents).toHaveLength(5), { timeout: 10000 });
+```
+
+**3. 역할 기반 에이전트 활용**
+
+```
+"/debug-doctor: 원인 분석 전문화
+/developer: 구현 전문화
+/refactor: 리팩토링 전문화
+각 역할에 맞는 질문을 하면 더 정확한 답변을 얻을 수 있습니다."
+```
+
+**4. 컨텍스트 제공의 중요성**
+
+```
+"AI에게 질문할 때:
+1. 구체적인 에러 정보 제공
+2. 관련 파일 명시 (@파일경로)
+3. 현재 상태 + 기대 결과 명시
+4. 작업 범위 명확화
+
+이렇게 하면 질문 횟수가 3-4회에서 1-2회로 줄어듭니다."
+```
+
+**5. TDD의 강점**
+
+```
+"테스트가 실패하는 상태부터 시작하면:
+- 무엇을 구현해야 하는지 명확
+- 언제 완료되었는지 알 수 있음
+- 리팩토링이 안전함
+
+240개 테스트가 모두 통과 = 모든 기능이 정상 작동"
+```
+
## AI가 잘하는 것과 못하는 것에 대해 고민한 적이 있나요? 내가 생각하는 지점에 대해 작성해주세요.
+**AI가 잘하는 것:**
+
+1. **패턴 기반 수정**
+
+ - 유사한 코드 패턴을 찾아 일괄 수정
+ - 예: 모든 테스트에 `waitFor` 추가
+
+2. **구조적 변경**
+
+ - 파일 구조 재구성
+ - 함수 추출/병합
+ - 타입 정의 추가
+
+3. **에러 원인 분석**
+
+ - 스택 트레이스 분석
+ - 로그 기반 디버깅
+ - 코드 플로우 추적
+
+4. **테스트 코드 생성**
+ - 설계 문서 기반 테스트 작성
+ - 유사 테스트 패턴 재사용
+
+**AI가 못하는 것 (한계):**
+
+1. **비즈니스 로직 판단**
+
+ - "이게 올바른 동작인가?" 판단 어려움
+ - 예: "종료 날짜 미입력 시 기본값 적용" - 이게 맞는지 AI는 모름
+
+2. **타이밍 이슈 이해**
+
+ - 비동기 상태 업데이트 타이밍 문제
+ - 테스트 안정화를 위한 적절한 `waitFor` 위치 판단
+ - **해결**: 구체적인 타임아웃 정보 제공 필요
+
+3. **전체 맥락 이해의 한계**
+
+ - 하나의 파일만 보면 다른 파일 영향 고려 못함
+ - 예: `App.tsx` 수정이 `useEventOperations.ts`에 영향
+ - **해결**: 관련 파일을 함께 제공
+
+4. **점진적 개선 방향 판단**
+
+ - 여러 문제 중 어떤 것부터 해결해야 할지 판단 어려움
+ - **해결**: 우선순위를 명시적으로 제시
+
+5. **실제 사용자 경험 판단**
+ - "이 UI/UX가 좋은가?" 판단 어려움
+ - 테스트로 검증 가능한 것만 판단
+
+**대응 전략:**
+
+- AI가 잘하는 것: 패턴 기반 작업 위임
+- AI가 못하는 것: 사람이 판단하고 구체적 지시
+
## 마지막으로 느낀점에 대해 적어주세요!
+
+**1. 테스트가 있으면 AI 개발이 완전히 달라집니다**
+
+- 테스트 = 명확한 완료 기준
+- 실패한 테스트 = 구체적인 수정 목표
+- 모든 테스트 통과 = 기능 완성 확신
+
+**2. 체계적인 워크플로우가 필수입니다**
+
+- Orchestrator처럼 단계를 나누고 검증하는 과정이 없으면
+- AI가 만들어낸 코드의 품질을 신뢰할 수 없음
+- 각 단계의 아티팩트(문서, 테스트)가 다음 단계의 입력이 되면서 일관성 확보
+
+**3. 디버깅 시간이 가장 많았습니다**
+
+- 초기 구현: 30% 시간
+- 테스트 안정화: 30% 시간
+- 디버깅: 40% 시간
+
+하지만 이 투자가 결국 신뢰성을 보장했습니다.
+
+**4. AI에게 구체적인 정보를 주는 것이 핵심입니다**
+
+- 에러 메시지, 관련 파일, 현재 상태
+- 이 세 가지만 명확히 하면 AI의 답변 품질이 급상승
+
+**5. "완벽하게 작동하는 코드"를 얻으려면 시간이 필요합니다**
+
+- 초기 생성 코드: 60% 정도만 작동
+- 디버깅 및 안정화: +40% 시간 투자
+- 하지만 최종적으로는 **240개 테스트가 모두 통과하는 안정적인 코드** 완성
+
+**6. TDD + AI = 강력한 조합**
+
+- AI가 테스트 코드를 생성하고
+- 테스트가 실패하는 것을 보면서
+- AI가 구현을 수정하고
+- 테스트가 통과할 때까지 반복
+- 이 사이클이 체계화되면 개발 속도와 품질 모두 확보
+
+**7. 실무 적용 가능성**
+
+- 이번에 구축한 에이전트 워크플로우를 실무에서 활용할 수 있을지 고민했습니다
+- 실무에서 반복적인 기능 개발에 대해서는 에이전트로 개발 효율성을 높일 수 있을 것 같습니다
+- 하지만 모든 기능을 에이전트에 맡기기보다는, 비즈니스 로직 판단이 필요한 부분은 사람이 직접 하고, 패턴이 반복되는 부분은 에이전트에게 위임하는 것이 좋겠습니다
+
+**결론:**
+AI는 도구입니다. 테스트와 워크플로우라는 "틀"이 있어야 그 도구를 효과적으로 활용할 수 있습니다. 이번 프로젝트를 통해 그 틀을 만드는 것이 얼마나 중요한지 깨달았습니다. 특히 실무에서 에이전트를 활용해 개발 효율성을 높이고 싶다면, 체계적인 워크플로우와 명확한 검증 기준이 필수적입니다.
diff --git a/server.js b/server.js
index cac56df9..8862ab39 100644
--- a/server.js
+++ b/server.js
@@ -14,14 +14,38 @@ app.use(express.json());
const dbName = process.env.TEST_ENV === 'e2e' ? 'e2e.json' : 'realEvents.json';
const getEvents = async () => {
- const data = await readFile(`${__dirname}/src/__mocks__/response/${dbName}`, 'utf8');
-
- return JSON.parse(data);
+ try {
+ const data = await readFile(`${__dirname}/src/__mocks__/response/${dbName}`, 'utf8');
+ const parsed = JSON.parse(data);
+ // 항상 { events: [...] } 형태로 정규화
+ if (Array.isArray(parsed)) {
+ return { events: parsed };
+ }
+ if (parsed && typeof parsed === 'object' && 'events' in parsed) {
+ return parsed;
+ }
+ // 빈 객체나 다른 형태면 빈 배열 반환
+ return { events: [] };
+ } catch (err) {
+ console.error('Error reading events file:', err);
+ // 파일 읽기 실패 시 빈 객체 반환
+ return { events: [] };
+ }
};
app.get('/api/events', async (_, res) => {
- const events = await getEvents();
- res.json(events);
+ try {
+ const data = await getEvents();
+ // getEvents()는 항상 { events: [...] } 형태를 반환
+ const list = Array.isArray(data?.events) ? data.events : [];
+ // Always respond with a consistent shape
+ res.json({ events: list });
+ } catch (err) {
+ console.error('GET /api/events error:', err);
+ console.error('Error stack:', err.stack);
+ // Fail-soft to avoid breaking the UI on first load
+ res.status(200).json({ events: [] });
+ }
});
app.post('/api/events', async (req, res) => {
diff --git a/src/App.tsx b/src/App.tsx
index 195c5b05..ed10c819 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,
@@ -14,7 +22,6 @@ import {
FormControlLabel,
FormLabel,
IconButton,
- MenuItem,
Select,
Stack,
Table,
@@ -35,8 +42,7 @@ 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 {
formatDate,
formatMonth,
@@ -46,6 +52,8 @@ import {
getWeeksAtMonth,
} from './utils/dateUtils';
import { findOverlappingEvents } from './utils/eventOverlap';
+import { isRepeatingEvent } from './utils/eventTypeChecker';
+import { findRepeatGroup } from './utils/repeatGroupUtils';
import { getTimeErrorMessage } from './utils/timeValidation';
const categories = ['업무', '개인', '가족', '기타'];
@@ -77,11 +85,11 @@ function App() {
isRepeating,
setIsRepeating,
repeatType,
- // setRepeatType,
+ setRepeatType,
repeatInterval,
- // setRepeatInterval,
+ setRepeatInterval,
repeatEndDate,
- // setRepeatEndDate,
+ setRepeatEndDate,
notificationTime,
setNotificationTime,
startTimeError,
@@ -94,8 +102,12 @@ function App() {
editEvent,
} = useEventForm();
- const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () =>
- setEditingEvent(null)
+ const { events, fetchEvents, saveEvent, deleteEvent } = useEventOperations(
+ Boolean(editingEvent),
+ () => {
+ setEditingEvent(null);
+ setEditMode(null); // Reset edit mode after save
+ }
);
const { notifications, notifiedEvents, setNotifications } = useNotifications(events);
@@ -105,8 +117,50 @@ function App() {
const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false);
const [overlappingEvents, setOverlappingEvents] = useState([]);
+ // Feature 4: 반복 일정 수정 모드 선택
+ const [isEditModeDialogOpen, setIsEditModeDialogOpen] = useState(false);
+ const [pendingEditEvent, setPendingEditEvent] = useState(null);
+ const [editMode, setEditMode] = useState<'single' | 'all' | null>(null);
+
+ // Feature 5: 반복 일정 삭제 모드 선택
+ const [isDeleteModeDialogOpen, setIsDeleteModeDialogOpen] = useState(false);
+ const [pendingDeleteEvent, setPendingDeleteEvent] = useState(null);
+
const { enqueueSnackbar } = useSnackbar();
+ // Override editEvent to show dialog for repeating events
+ const handleEditEvent = (event: Event) => {
+ if (event.repeat.type !== 'none') {
+ // 반복 일정이면 다이얼로그 표시
+ setPendingEditEvent(event);
+ setIsEditModeDialogOpen(true);
+ } else {
+ // 일반 일정이면 바로 편집 모드로
+ editEvent(event);
+ }
+ };
+
+ // Handle delete click with dialog for repeating events (Feature 5)
+ const handleDeleteEventClick = (event: Event) => {
+ if (event.repeat.type !== 'none') {
+ setPendingDeleteEvent(event);
+ setIsDeleteModeDialogOpen(true);
+ } else {
+ // 일반 일정은 즉시 삭제
+ deleteEvent(event.id);
+ }
+ };
+
+ // Handle edit mode selection from dialog
+ const handleEditModeSelection = (mode: 'single' | 'all') => {
+ setEditMode(mode);
+ setIsEditModeDialogOpen(false);
+ if (pendingEditEvent) {
+ editEvent(pendingEditEvent);
+ setPendingEditEvent(null);
+ }
+ };
+
const addOrUpdateEvent = async () => {
if (!title || !date || !startTime || !endTime) {
enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' });
@@ -135,13 +189,19 @@ function App() {
notificationTime,
};
- const overlapping = findOverlappingEvents(eventData, events);
- if (overlapping.length > 0) {
- setOverlappingEvents(overlapping);
- setIsOverlapDialogOpen(true);
- } else {
- await saveEvent(eventData);
+ // 반복 일정은 일정 겹침을 고려하지 않음
+ if (isRepeating) {
+ await saveEvent(eventData, editMode, events); // Pass editMode and events for group editing
resetForm();
+ } else {
+ const overlapping = findOverlappingEvents(eventData, events);
+ if (overlapping.length > 0) {
+ setOverlappingEvents(overlapping);
+ setIsOverlapDialogOpen(true);
+ } else {
+ await saveEvent(eventData, editMode, events); // Pass editMode and events for group editing
+ resetForm();
+ }
}
};
@@ -200,6 +260,9 @@ function App() {
}}
>
+ {isRepeatingEvent(event) && (
+
+ )}
{isNotified && }
+ {isRepeatingEvent(event) && (
+
+ )}
{isNotified && }
제목 setTitle(e.target.value)}
+ inputProps={{
+ id: 'title',
+ 'data-testid': 'title-input',
+ }}
/>
날짜 setDate(e.target.value)}
+ inputProps={{
+ id: 'date',
+ 'data-testid': 'date-input',
+ }}
/>
@@ -345,13 +417,16 @@ function App() {
시작 시간 getTimeErrorMessage(startTime, endTime)}
error={!!startTimeError}
+ inputProps={{
+ id: 'start-time',
+ 'data-testid': 'start-time-input',
+ }}
/>
@@ -359,13 +434,16 @@ function App() {
종료 시간 getTimeErrorMessage(startTime, endTime)}
error={!!endTimeError}
+ inputProps={{
+ id: 'end-time',
+ 'data-testid': 'end-time-input',
+ }}
/>
@@ -374,26 +452,33 @@ function App() {
설명 setDescription(e.target.value)}
+ inputProps={{
+ id: 'description',
+ 'aria-label': '설명',
+ }}
/>
위치 setLocation(e.target.value)}
+ inputProps={{
+ id: 'location',
+ 'aria-label': '위치',
+ }}
/>
카테고리
@@ -414,7 +499,15 @@ function App() {
control={
setIsRepeating(e.target.checked)}
+ onChange={(e) => {
+ const checked = e.target.checked;
+ setIsRepeating(checked);
+ if (checked && repeatType === 'none') {
+ setRepeatType('daily');
+ } else if (!checked) {
+ setRepeatType('none');
+ }
+ }}
/>
}
label="반복 일정"
@@ -424,33 +517,39 @@ function App() {
알림 설정
{/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */}
- {/* {isRepeating && (
+ {isRepeating && (
- 반복 유형
+ 반복 유형
@@ -465,17 +564,21 @@ function App() {
/>
- 반복 종료일
+ 반복 종료일 setRepeatEndDate(e.target.value)}
+ inputProps={{
+ 'data-testid': 'repeat-end-date-input',
+ }}
/>
- )} */}
+ )}
navigate('next')}>
@@ -540,6 +644,7 @@ function App() {
+ {isRepeatingEvent(event) && }
{notifiedEvents.includes(event.id) && }
- editEvent(event)}>
+ handleEditEvent(event)}>
- deleteEvent(event.id)}>
+ handleDeleteEventClick(event)}>
@@ -609,22 +714,26 @@ function App() {
color="error"
onClick={() => {
setIsOverlapDialogOpen(false);
- saveEvent({
- id: editingEvent ? editingEvent.id : undefined,
- title,
- date,
- startTime,
- endTime,
- description,
- location,
- category,
- repeat: {
- type: isRepeating ? repeatType : 'none',
- interval: repeatInterval,
- endDate: repeatEndDate || undefined,
+ saveEvent(
+ {
+ id: editingEvent ? editingEvent.id : undefined,
+ title,
+ date,
+ startTime,
+ endTime,
+ description,
+ location,
+ category,
+ repeat: {
+ type: isRepeating ? repeatType : 'none',
+ interval: repeatInterval,
+ endDate: repeatEndDate || undefined,
+ },
+ notificationTime,
},
- notificationTime,
- });
+ editMode,
+ events
+ );
}}
>
계속 진행
@@ -632,6 +741,80 @@ function App() {
+ {/* Feature 4: 반복 일정 수정 모드 선택 다이얼로그 */}
+
+
+ {/* Feature 5: 반복 일정 삭제 모드 선택 다이얼로그 */}
+
+
{notifications.length > 0 && (
{notifications.map((notification, index) => (
diff --git a/src/__mocks__/response/realEvents.json b/src/__mocks__/response/realEvents.json
index 821aef58..d042adda 100644
--- a/src/__mocks__/response/realEvents.json
+++ b/src/__mocks__/response/realEvents.json
@@ -1,64 +1 @@
-{
- "events": [
- {
- "id": "2b7545a6-ebee-426c-b906-2329bc8d62bd",
- "title": "팀 회의",
- "date": "2025-10-20",
- "startTime": "10:00",
- "endTime": "11:00",
- "description": "주간 팀 미팅",
- "location": "회의실 A",
- "category": "업무",
- "repeat": { "type": "none", "interval": 0 },
- "notificationTime": 1
- },
- {
- "id": "09702fb3-a478-40b3-905e-9ab3c8849dcd",
- "title": "점심 약속",
- "date": "2025-10-21",
- "startTime": "12:30",
- "endTime": "13:30",
- "description": "동료와 점심 식사",
- "location": "회사 근처 식당",
- "category": "개인",
- "repeat": { "type": "none", "interval": 0 },
- "notificationTime": 1
- },
- {
- "id": "da3ca408-836a-4d98-b67a-ca389d07552b",
- "title": "프로젝트 마감",
- "date": "2025-10-25",
- "startTime": "09:00",
- "endTime": "18:00",
- "description": "분기별 프로젝트 마감",
- "location": "사무실",
- "category": "업무",
- "repeat": { "type": "none", "interval": 0 },
- "notificationTime": 1
- },
- {
- "id": "dac62941-69e5-4ec0-98cc-24c2a79a7f81",
- "title": "생일 파티",
- "date": "2025-10-28",
- "startTime": "19:00",
- "endTime": "22:00",
- "description": "친구 생일 축하",
- "location": "친구 집",
- "category": "개인",
- "repeat": { "type": "none", "interval": 0 },
- "notificationTime": 1
- },
- {
- "id": "80d85368-b4a4-47b3-b959-25171d49371f",
- "title": "운동",
- "date": "2025-10-22",
- "startTime": "18:00",
- "endTime": "19:00",
- "description": "주간 운동",
- "location": "헬스장",
- "category": "개인",
- "repeat": { "type": "none", "interval": 0 },
- "notificationTime": 1
- }
- ]
-}
+{"events":[]}
\ No newline at end of file
diff --git a/src/__tests__/integration/feature1-integration.spec.tsx b/src/__tests__/integration/feature1-integration.spec.tsx
new file mode 100644
index 00000000..459c00a2
--- /dev/null
+++ b/src/__tests__/integration/feature1-integration.spec.tsx
@@ -0,0 +1,815 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SnackbarProvider } from 'notistack';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+
+import App from '../../App';
+import { Event } from '../../types';
+
+// Mock API calls
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+// Mock server response
+const mockEvents: Event[] = [];
+
+const renderApp = () => {
+ return render(
+
+
+
+ );
+};
+
+describe('FEATURE1: 반복 유형 선택', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetch.mockResolvedValue(
+ new Response(JSON.stringify({ events: mockEvents }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ });
+
+ describe('Story 1: 반복 설정 활성화', () => {
+ it('TC-1-1 - 반복 설정 체크박스 클릭 시 반복 드롭다운이 표시된다', async () => {
+ renderApp();
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+
+ // 반복 드롭다운이 처음에는 보이지 않음을 확인
+ expect(screen.queryByLabelText('반복 유형')).not.toBeInTheDocument();
+
+ await userEvent.click(repeatCheckbox);
+
+ // 체크박스가 체크되었는지 확인
+ expect(repeatCheckbox).toBeChecked();
+
+ // 반복 드롭다운이 표시되는지 확인
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+ });
+
+ it('TC-1-2 - 반복 설정 체크박스 해제 시 반복 드롭다운이 숨겨진다', async () => {
+ renderApp();
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+
+ // 체크박스 클릭하여 활성화
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ // 다시 클릭하여 해제
+ await userEvent.click(repeatCheckbox);
+
+ // 체크박스가 해제되었는지 확인
+ expect(repeatCheckbox).not.toBeChecked();
+
+ // 반복 드롭다운이 숨겨졌는지 확인
+ expect(screen.queryByLabelText('반복 유형')).not.toBeInTheDocument();
+ });
+
+ it('TC-1-3 - 반복 설정 토글 동작이 정상적으로 작동한다', async () => {
+ renderApp();
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+
+ // 첫 번째 클릭 - 활성화
+ await userEvent.click(repeatCheckbox);
+ expect(repeatCheckbox).toBeChecked();
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ // 두 번째 클릭 - 비활성화
+ await userEvent.click(repeatCheckbox);
+ expect(repeatCheckbox).not.toBeChecked();
+ expect(screen.queryByLabelText('반복 유형')).not.toBeInTheDocument();
+
+ // 세 번째 클릭 - 다시 활성화
+ await userEvent.click(repeatCheckbox);
+ expect(repeatCheckbox).toBeChecked();
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Story 2: 반복 유형 선택', () => {
+ it('TC-2-1 - 매일 반복 선택 시 매일 반복 설정이 적용된다', async () => {
+ renderApp();
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatTypeSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatTypeSelect, 'daily');
+
+ expect(repeatTypeSelect).toHaveValue('daily');
+ });
+
+ it('TC-2-2 - 매주 반복 선택 시 매주 반복 설정이 적용된다', async () => {
+ renderApp();
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatTypeSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatTypeSelect, 'weekly');
+
+ expect(repeatTypeSelect).toHaveValue('weekly');
+ });
+
+ it('TC-2-3 - 매월 반복 선택 시 매월 반복 설정이 적용된다', async () => {
+ renderApp();
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatTypeSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatTypeSelect, 'monthly');
+
+ expect(repeatTypeSelect).toHaveValue('monthly');
+ });
+
+ it('TC-2-4 - 매년 반복 선택 시 매년 반복 설정이 적용된다', async () => {
+ renderApp();
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatTypeSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatTypeSelect, 'yearly');
+
+ expect(repeatTypeSelect).toHaveValue('yearly');
+ });
+
+ it('TC-2-5 - 드롭다운에 모든 반복 옵션이 표시된다', async () => {
+ renderApp();
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatTypeSelect = screen.getByLabelText('반복 유형');
+ const options = Array.from(repeatTypeSelect.querySelectorAll('option')).map(
+ (option) => option.value
+ );
+
+ expect(options).toContain('daily');
+ expect(options).toContain('weekly');
+ expect(options).toContain('monthly');
+ expect(options).toContain('yearly');
+ expect(options.length).toBeGreaterThanOrEqual(4);
+ });
+ });
+
+ describe('Story 3: 반복 일정 생성', () => {
+ it('TC-3-1 - 매일 반복 일정이 정상적으로 생성된다', async () => {
+ const generatedEvents: Event[] = [];
+ for (let i = 0; i < 7; i++) {
+ const date = new Date(2024, 0, 1 + i);
+ generatedEvents.push({
+ id: `daily-${i}`,
+ title: '매일 회의',
+ date: date.toISOString().split('T')[0],
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'daily', interval: 1 },
+ notificationTime: 10,
+ });
+ }
+
+ mockFetch.mockImplementation((input) => {
+ const method = typeof input === 'string' ? 'GET' : (input as Request).method || 'GET';
+ if (method === 'POST') {
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: true }), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ events: generatedEvents }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ });
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ await userEvent.type(screen.getByTestId('title-input'), '매일 회의');
+ await userEvent.type(screen.getByTestId('date-input'), '2024-01-01');
+ await userEvent.type(screen.getByTestId('start-time-input'), '10:00');
+ await userEvent.type(screen.getByTestId('end-time-input'), '11:00');
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const submitButton = screen.getByTestId('event-submit-button');
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ const postCall = mockFetch.mock.calls.find((call) => {
+ const request = call[0];
+ return (
+ request &&
+ typeof request === 'object' &&
+ 'url' in request &&
+ request.url?.includes('/api/events-list')
+ );
+ });
+ expect(postCall).toBeTruthy();
+ });
+
+ // Verify generated events count and type
+ await waitFor(() => {
+ expect(generatedEvents.length).toBe(7);
+ generatedEvents.forEach((event) => {
+ expect(event.repeat.type).toBe('daily');
+ });
+ });
+ });
+
+ it('TC-3-2 - 매주 반복 일정이 정상적으로 생성된다', async () => {
+ const generatedEvents: Event[] = [];
+ for (let i = 0; i < 4; i++) {
+ const date = new Date(2024, 0, 1 + i * 7);
+ generatedEvents.push({
+ id: `weekly-${i}`,
+ title: '매주 회의',
+ date: date.toISOString().split('T')[0],
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ });
+ }
+
+ mockFetch.mockImplementation((input) => {
+ const method = typeof input === 'string' ? 'GET' : (input as Request).method || 'GET';
+ if (method === 'POST') {
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: true }), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ events: generatedEvents }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ });
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ await userEvent.type(screen.getByTestId('title-input'), '매주 회의');
+ await userEvent.type(screen.getByTestId('date-input'), '2024-01-01');
+ await userEvent.type(screen.getByTestId('start-time-input'), '10:00');
+ await userEvent.type(screen.getByTestId('end-time-input'), '11:00');
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatSelect, 'weekly');
+
+ const submitButton = screen.getByTestId('event-submit-button');
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ const postCall = mockFetch.mock.calls.find((call) => {
+ const request = call[0];
+ return (
+ request &&
+ typeof request === 'object' &&
+ 'url' in request &&
+ request.url?.includes('/api/events-list')
+ );
+ });
+ expect(postCall).toBeTruthy();
+ });
+
+ // Verify generated events count and type
+ await waitFor(() => {
+ expect(generatedEvents.length).toBe(4);
+ generatedEvents.forEach((event) => {
+ expect(event.repeat.type).toBe('weekly');
+ });
+ });
+ });
+
+ it('TC-3-3 - 매월 반복 일정이 정상적으로 생성된다', async () => {
+ const generatedEvents: Event[] = [];
+ for (let i = 0; i < 6; i++) {
+ generatedEvents.push({
+ id: `monthly-${i}`,
+ title: '매월 회의',
+ date: `2024-${String(i + 1).padStart(2, '0')}-15`,
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'monthly', interval: 1 },
+ notificationTime: 10,
+ });
+ }
+
+ mockFetch.mockImplementation((input) => {
+ const method = typeof input === 'string' ? 'GET' : (input as Request).method || 'GET';
+ if (method === 'POST') {
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: true }), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ events: generatedEvents }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ });
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ await userEvent.type(screen.getByTestId('title-input'), '매월 회의');
+ await userEvent.type(screen.getByTestId('date-input'), '2024-01-15');
+ await userEvent.type(screen.getByTestId('start-time-input'), '10:00');
+ await userEvent.type(screen.getByTestId('end-time-input'), '11:00');
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatSelect, 'monthly');
+
+ const submitButton = screen.getByTestId('event-submit-button');
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ const postCall = mockFetch.mock.calls.find((call) => {
+ const request = call[0];
+ return (
+ request &&
+ typeof request === 'object' &&
+ 'url' in request &&
+ request.url?.includes('/api/events-list')
+ );
+ });
+ expect(postCall).toBeTruthy();
+ });
+
+ // Verify generated events count and dates
+ await waitFor(() => {
+ expect(generatedEvents.length).toBe(6);
+ generatedEvents.forEach((event) => {
+ expect(event.repeat.type).toBe('monthly');
+ expect(event.date.endsWith('-15')).toBe(true);
+ });
+ });
+ });
+
+ it('TC-3-4 - 매년 반복 일정이 정상적으로 생성된다', async () => {
+ const generatedEvents: Event[] = [];
+ for (let i = 0; i < 5; i++) {
+ generatedEvents.push({
+ id: `yearly-${i}`,
+ title: '매년 기념일',
+ date: `${2024 + i}-03-15`,
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'yearly', interval: 1 },
+ notificationTime: 10,
+ });
+ }
+
+ mockFetch.mockImplementation((input) => {
+ const method = typeof input === 'string' ? 'GET' : (input as Request).method || 'GET';
+ if (method === 'POST') {
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: true }), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ events: generatedEvents }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ });
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ await userEvent.type(screen.getByTestId('title-input'), '매년 기념일');
+ await userEvent.type(screen.getByTestId('date-input'), '2024-03-15');
+ await userEvent.type(screen.getByTestId('start-time-input'), '10:00');
+ await userEvent.type(screen.getByTestId('end-time-input'), '11:00');
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatSelect, 'yearly');
+
+ const submitButton = screen.getByTestId('event-submit-button');
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ const postCall = mockFetch.mock.calls.find((call) => {
+ const request = call[0];
+ return (
+ request &&
+ typeof request === 'object' &&
+ 'url' in request &&
+ request.url?.includes('/api/events-list')
+ );
+ });
+ expect(postCall).toBeTruthy();
+ });
+
+ // Verify generated events count and dates
+ await waitFor(() => {
+ expect(generatedEvents.length).toBe(5);
+ generatedEvents.forEach((event) => {
+ expect(event.repeat.type).toBe('yearly');
+ expect(event.date.endsWith('-03-15')).toBe(true);
+ });
+ });
+ });
+
+ it('TC-3-5 - 31일 매월 반복 시 2월에는 생성되지 않는다 (예외)', async () => {
+ const monthsWith31Days = [1, 3, 5, 7, 8, 10, 12];
+ const generatedEvents: Event[] = monthsWith31Days.map((month, index) => ({
+ id: `monthly-31-${index}`,
+ title: '31일 회의',
+ date: `2024-${String(month).padStart(2, '0')}-31`,
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'monthly', interval: 1 },
+ notificationTime: 10,
+ }));
+
+ mockFetch.mockImplementation((input) => {
+ const method = typeof input === 'string' ? 'GET' : (input as Request).method || 'GET';
+ if (method === 'POST') {
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: true }), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ events: generatedEvents }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ });
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ await userEvent.type(screen.getByTestId('title-input'), '31일 회의');
+ await userEvent.type(screen.getByTestId('date-input'), '2024-01-31');
+ await userEvent.type(screen.getByTestId('start-time-input'), '10:00');
+ await userEvent.type(screen.getByTestId('end-time-input'), '11:00');
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatSelect, 'monthly');
+
+ const submitButton = screen.getByTestId('event-submit-button');
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalled();
+ const calls = mockFetch.mock.calls;
+ const postCall = calls.find((call) => {
+ const request = call[0];
+ return (
+ request &&
+ typeof request === 'object' &&
+ 'url' in request &&
+ request.url?.includes('/api/events-list')
+ );
+ });
+ expect(postCall).toBeDefined();
+ });
+
+ await waitFor(() => {
+ const hasFebruaryEvent = generatedEvents.some((event) => event.date.includes('-02-'));
+ expect(hasFebruaryEvent).toBe(false);
+ });
+ });
+
+ it('TC-3-6 - 31일 매월 반복 시 30일까지 있는 월에는 생성 안됨', async () => {
+ const monthsWith31Days = [1, 3, 5, 7, 8, 10, 12];
+ const generatedEvents: Event[] = monthsWith31Days.map((month, index) => ({
+ id: `monthly-31-${index}`,
+ title: '31일 회의',
+ date: `2024-${String(month).padStart(2, '0')}-31`,
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'monthly', interval: 1 },
+ notificationTime: 10,
+ }));
+
+ mockFetch.mockImplementation((input) => {
+ const method = typeof input === 'string' ? 'GET' : (input as Request).method || 'GET';
+ if (method === 'POST') {
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: true }), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ events: generatedEvents }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ });
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ await userEvent.type(screen.getByTestId('title-input'), '31일 회의');
+ await userEvent.type(screen.getByTestId('date-input'), '2024-01-31');
+ await userEvent.type(screen.getByTestId('start-time-input'), '10:00');
+ await userEvent.type(screen.getByTestId('end-time-input'), '11:00');
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatSelect, 'monthly');
+
+ const submitButton = screen.getByTestId('event-submit-button');
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalled();
+ const calls = mockFetch.mock.calls;
+ const postCall = calls.find((call) => {
+ const request = call[0];
+ return (
+ request &&
+ typeof request === 'object' &&
+ 'url' in request &&
+ request.url?.includes('/api/events-list')
+ );
+ });
+ expect(postCall).toBeDefined();
+ });
+
+ await waitFor(() => {
+ const monthsWith30Days = [4, 6, 9, 11];
+ monthsWith30Days.forEach((month) => {
+ const has30DayMonthEvent = generatedEvents.some((event) =>
+ event.date.includes(`-${String(month).padStart(2, '0')}-`)
+ );
+ expect(has30DayMonthEvent).toBe(false);
+ });
+ });
+ });
+
+ it('TC-3-7 - 윤년 29일 매년 반복 시 평년에는 생성 안됨 (예외)', async () => {
+ const leapYears = [2024, 2028];
+ const generatedEvents: Event[] = leapYears.map((year, index) => ({
+ id: `yearly-leapday-${index}`,
+ title: '윤년 기념일',
+ date: `${year}-02-29`,
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'yearly', interval: 1 },
+ notificationTime: 10,
+ }));
+
+ mockFetch.mockImplementation((input) => {
+ const method = typeof input === 'string' ? 'GET' : (input as Request).method || 'GET';
+ if (method === 'POST') {
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: true }), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ events: generatedEvents }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ });
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ await userEvent.type(screen.getByTestId('title-input'), '윤년 기념일');
+ await userEvent.type(screen.getByTestId('date-input'), '2024-02-29');
+ await userEvent.type(screen.getByTestId('start-time-input'), '10:00');
+ await userEvent.type(screen.getByTestId('end-time-input'), '11:00');
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatSelect, 'yearly');
+
+ const submitButton = screen.getByTestId('event-submit-button');
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalled();
+ const calls = mockFetch.mock.calls;
+ const postCall = calls.find((call) => {
+ const request = call[0];
+ return (
+ request &&
+ typeof request === 'object' &&
+ 'url' in request &&
+ request.url?.includes('/api/events-list')
+ );
+ });
+ expect(postCall).toBeDefined();
+ });
+
+ await waitFor(() => {
+ const nonLeapYears = [2025, 2026, 2027];
+ nonLeapYears.forEach((year) => {
+ const hasNonLeapYearEvent = generatedEvents.some((event) =>
+ event.date.startsWith(`${year}-`)
+ );
+ expect(hasNonLeapYearEvent).toBe(false);
+ });
+ });
+ });
+
+ it('TC-3-8 - 윤년 29일 매년 반복 시 다음 윤년에는 생성됨', async () => {
+ const leapYears = [2024, 2028];
+ const generatedEvents: Event[] = leapYears.map((year, index) => ({
+ id: `yearly-leapday-${index}`,
+ title: '윤년 기념일',
+ date: `${year}-02-29`,
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'yearly', interval: 1 },
+ notificationTime: 10,
+ }));
+
+ mockFetch.mockImplementation((input) => {
+ const method = typeof input === 'string' ? 'GET' : (input as Request).method || 'GET';
+ if (method === 'POST') {
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: true }), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ events: generatedEvents }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+ });
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ await userEvent.type(screen.getByTestId('title-input'), '윤년 기념일');
+ await userEvent.type(screen.getByTestId('date-input'), '2024-02-29');
+ await userEvent.type(screen.getByTestId('start-time-input'), '10:00');
+ await userEvent.type(screen.getByTestId('end-time-input'), '11:00');
+
+ const repeatCheckbox = screen.getByLabelText('반복 일정');
+ await userEvent.click(repeatCheckbox);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+
+ const repeatSelect = screen.getByLabelText('반복 유형');
+ await userEvent.selectOptions(repeatSelect, 'yearly');
+
+ const submitButton = screen.getByTestId('event-submit-button');
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalled();
+ const calls = mockFetch.mock.calls;
+ const postCall = calls.find((call) => {
+ const request = call[0];
+ return (
+ request &&
+ typeof request === 'object' &&
+ 'url' in request &&
+ request.url?.includes('/api/events-list')
+ );
+ });
+ expect(postCall).toBeDefined();
+ });
+
+ await waitFor(() => {
+ const has2028Event = generatedEvents.some((event) => event.date === '2028-02-29');
+ expect(has2028Event).toBe(true);
+ });
+ });
+ });
+});
diff --git a/src/__tests__/integration/feature2-integration.spec.tsx b/src/__tests__/integration/feature2-integration.spec.tsx
new file mode 100644
index 00000000..00364a01
--- /dev/null
+++ b/src/__tests__/integration/feature2-integration.spec.tsx
@@ -0,0 +1,286 @@
+/**
+ * FEATURE2 통합 테스트: 반복 일정 표시
+ * Epic: 반복 일정 시각적 구분
+ *
+ * 이 테스트는 캘린더 뷰(월간/주간)와 일정 목록에서
+ * 반복 일정이 아이콘으로 시각적으로 구분되는지 검증합니다.
+ */
+
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { SnackbarProvider } from 'notistack';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import App from '../../App';
+import { server } from '../../setupTests';
+import { Event } from '../../types';
+
+// Mock events with repeating and normal events (2025년 10월 1일 기준, 주간 뷰에서도 보이도록)
+const mockRepeatingEventWeekly: Event = {
+ id: 'repeat-weekly-1',
+ title: '매주 회의',
+ date: '2025-10-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 반복 일정',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+};
+
+const mockRepeatingEventDaily: Event = {
+ id: 'repeat-daily-1',
+ title: '매일 스탠드업',
+ date: '2025-10-02',
+ startTime: '09:00',
+ endTime: '09:30',
+ description: '일일 반복 일정',
+ location: '팀룸',
+ category: '업무',
+ repeat: { type: 'daily', interval: 1 },
+ notificationTime: 10,
+};
+
+const mockRepeatingEventMonthly: Event = {
+ id: 'repeat-monthly-1',
+ title: '월간 보고',
+ date: '2025-10-03',
+ startTime: '15:00',
+ endTime: '16:00',
+ description: '월간 반복 일정',
+ location: '본사',
+ category: '업무',
+ repeat: { type: 'monthly', interval: 1 },
+ notificationTime: 10,
+};
+
+const mockRepeatingEventYearly: Event = {
+ id: 'repeat-yearly-1',
+ title: '연간 평가',
+ date: '2025-10-04',
+ startTime: '14:00',
+ endTime: '17:00',
+ description: '연간 반복 일정',
+ location: '대회의실',
+ category: '업무',
+ repeat: { type: 'yearly', interval: 1 },
+ notificationTime: 10,
+};
+
+const mockNormalEvent: Event = {
+ id: 'normal-1',
+ title: '일반 회의',
+ date: '2025-10-05',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '일반 일정',
+ location: '회의실 B',
+ category: '개인',
+ repeat: { type: 'none', interval: 1 },
+ notificationTime: 10,
+};
+
+const renderApp = () => {
+ return render(
+
+
+
+ );
+};
+
+/**
+ * Helper: 일정 제목 근처에서 반복 아이콘을 찾는 헬퍼 함수
+ * DOM 구조 변경에 강하도록 여러 탐색 전략을 시도합니다.
+ */
+function findRepeatIconNearTitle(titleElement: HTMLElement): HTMLElement | null {
+ // 1. 부모 요소에서 aria-label로 아이콘 찾기
+ const parent = titleElement.parentElement;
+ if (parent) {
+ const icon = within(parent).queryByLabelText('반복 일정');
+ if (icon) return icon;
+ }
+
+ // 2. 조부모 요소에서 찾기 (중첩 구조 대응)
+ const grandParent = parent?.parentElement;
+ if (grandParent) {
+ const icon = within(grandParent).queryByLabelText('반복 일정');
+ if (icon) return icon;
+ }
+
+ return null;
+}
+
+describe('FEATURE2: 반복 일정 표시 (Epic: 반복 일정 시각적 구분)', () => {
+ beforeEach(() => {
+ // MSW 핸들러를 사용하여 Mock 데이터 설정
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({
+ events: [
+ mockRepeatingEventWeekly,
+ mockRepeatingEventDaily,
+ mockRepeatingEventMonthly,
+ mockRepeatingEventYearly,
+ mockNormalEvent,
+ ],
+ });
+ })
+ );
+ });
+
+ // ----- Story 1: 캘린더 뷰에서 반복 일정 아이콘 표시 -----
+ describe('Story 1: 캘린더 뷰에서 반복 일정 아이콘 표시', () => {
+ it('TC-2-1-1 - 월간 뷰에서 반복 일정이 아이콘과 함께 표시된다', async () => {
+ // Arrange: 앱 렌더링 및 월간 뷰 확인
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // 월간 뷰가 기본값이므로 별도 전환 불필요
+ const monthView = screen.getByTestId('month-view');
+ expect(monthView).toBeInTheDocument();
+
+ // Act: 월간 뷰에서 반복 일정 찾기
+ const repeatingEventTitle = await within(monthView).findByText('매주 회의');
+ expect(repeatingEventTitle).toBeInTheDocument();
+
+ // Assert: 반복 일정 옆에 반복 아이콘이 표시되는지 확인
+ const repeatIcon = findRepeatIconNearTitle(repeatingEventTitle);
+ expect(repeatIcon).toBeInTheDocument();
+ });
+
+ it('TC-2-1-2 - 주간 뷰에서 반복 일정이 아이콘과 함께 표시된다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 주간 뷰로 전환
+ const viewSelectWrapper = screen.getByLabelText('뷰 타입 선택');
+ const viewSelect = within(viewSelectWrapper).getByRole('combobox');
+ await userEvent.selectOptions(viewSelect, 'week');
+
+ // 주간 뷰 확인
+ const weekView = await screen.findByTestId('week-view');
+ expect(weekView).toBeInTheDocument();
+
+ // 주간 뷰에서 반복 일정 찾기
+ const repeatingEventTitle = await within(weekView).findByText('매주 회의');
+ expect(repeatingEventTitle).toBeInTheDocument();
+
+ // Assert: 반복 일정 옆에 반복 아이콘이 표시되는지 확인
+ const repeatIcon = findRepeatIconNearTitle(repeatingEventTitle);
+ expect(repeatIcon).toBeInTheDocument();
+ });
+
+ it('TC-2-1-3 - 일반 일정은 아이콘 없이 제목만 표시된다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 월간 뷰에서 일반 일정 찾기
+ const monthView = screen.getByTestId('month-view');
+ const normalEventTitle = await within(monthView).findByText('일반 회의');
+ expect(normalEventTitle).toBeInTheDocument();
+
+ // Assert: 일반 일정에는 반복 아이콘이 없어야 함
+ const repeatIcon = findRepeatIconNearTitle(normalEventTitle);
+ expect(repeatIcon).not.toBeInTheDocument();
+ });
+ });
+
+ // ----- Story 2: 일정 목록에서 반복 일정 아이콘 표시 -----
+ describe('Story 2: 일정 목록에서 반복 일정 아이콘 표시', () => {
+ it('TC-2-2-1 - 일정 목록에서 반복 일정이 아이콘과 함께 표시된다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일정 목록(오른쪽 사이드바)에서 반복 일정 찾기
+ const eventList = screen.getByTestId('event-list');
+ const repeatingEventTitle = await within(eventList).findByText('매주 회의');
+ expect(repeatingEventTitle).toBeInTheDocument();
+
+ // Assert: 반복 일정 옆에 반복 아이콘이 표시되는지 확인
+ const repeatIcon = findRepeatIconNearTitle(repeatingEventTitle);
+ expect(repeatIcon).toBeInTheDocument();
+ });
+
+ it('TC-2-2-2 - 일정 목록에서 일반 일정은 아이콘 없이 표시된다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일정 목록에서 일반 일정 찾기
+ const eventList = screen.getByTestId('event-list');
+ const normalEventTitle = await within(eventList).findByText('일반 회의');
+ expect(normalEventTitle).toBeInTheDocument();
+
+ // Assert: 일반 일정에는 반복 아이콘이 없어야 함
+ const repeatIcon = findRepeatIconNearTitle(normalEventTitle);
+ expect(repeatIcon).not.toBeInTheDocument();
+ });
+ });
+
+ // ----- Story 3: 반복 일정 아이콘 일관성 유지 -----
+ describe('Story 3: 반복 일정 아이콘 일관성 유지', () => {
+ it('TC-2-3-1 - 모든 반복 유형이 동일한 아이콘으로 표시된다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 모든 반복 아이콘 수집
+ const allRepeatIcons = await screen.findAllByLabelText('반복 일정');
+ expect(allRepeatIcons.length).toBeGreaterThan(0);
+
+ // Assert: 모든 아이콘이 접근성 레이블을 통해 식별 가능한지 확인
+ // findAllByLabelText로 찾았으므로 이미 '반복 일정'이라는 레이블을 통해 식별됨
+ // 아이콘의 일관성을 위해 동일한 aria-label 또는 레이블을 가지고 있어야 함
+ const labelsWithValue = allRepeatIcons
+ .map((icon) => icon.getAttribute('aria-label'))
+ .filter((label) => label !== null);
+
+ // 최소한 일부 아이콘은 aria-label을 직접 가져야 함
+ expect(labelsWithValue.length).toBeGreaterThan(0);
+
+ // 모든 aria-label은 '반복 일정'이어야 함
+ labelsWithValue.forEach((label) => {
+ expect(label).toBe('반복 일정');
+ });
+ });
+
+ it('TC-2-3-2 - 아이콘 위치가 일정 제목 앞에 일관되게 표시된다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 여러 반복 유형의 일정 제목 찾기 (일정 목록에서만 확인)
+ const eventList = screen.getByTestId('event-list');
+ const weeklyEventTitle = await within(eventList).findByText('매주 회의');
+ const dailyEventTitle = await within(eventList).findByText('매일 스탠드업');
+ const monthlyEventTitle = await within(eventList).findByText('월간 보고');
+
+ // Assert: 각 반복 일정에 아이콘이 존재하는지 확인
+ const weeklyIcon = findRepeatIconNearTitle(weeklyEventTitle);
+ const dailyIcon = findRepeatIconNearTitle(dailyEventTitle);
+ const monthlyIcon = findRepeatIconNearTitle(monthlyEventTitle);
+
+ expect(weeklyIcon).toBeInTheDocument();
+ expect(dailyIcon).toBeInTheDocument();
+ expect(monthlyIcon).toBeInTheDocument();
+
+ // 아이콘이 일관된 위치에 표시되는지 확인
+ // 헬퍼 함수가 동일한 탐색 로직(부모/조부모 요소 내 aria-label)으로
+ // 모든 아이콘을 성공적으로 찾았다면, 위치가 일관됨을 의미합니다.
+ // 추가 검증: 모든 아이콘이 제목과 같은 컨테이너 내에 존재하는지 확인
+ const weeklyParent = weeklyEventTitle.parentElement;
+ const dailyParent = dailyEventTitle.parentElement;
+ const monthlyParent = monthlyEventTitle.parentElement;
+
+ // 부모 요소가 존재하고, 그 안에 아이콘이 포함되어 있는지 확인
+ expect(weeklyParent).toContainElement(weeklyIcon as HTMLElement);
+ expect(dailyParent).toContainElement(dailyIcon as HTMLElement);
+ expect(monthlyParent).toContainElement(monthlyIcon as HTMLElement);
+ });
+ });
+});
diff --git a/src/__tests__/integration/feature3-integration.spec.tsx b/src/__tests__/integration/feature3-integration.spec.tsx
new file mode 100644
index 00000000..a2a51281
--- /dev/null
+++ b/src/__tests__/integration/feature3-integration.spec.tsx
@@ -0,0 +1,549 @@
+/**
+ * Integration Test: Feature 3 - 반복 일정 종료 조건
+ * Epic: 반복 일정 종료 관리
+ *
+ * 이 테스트는 반복 일정의 종료 날짜 설정, 검증, 수정 및 표시 기능을 검증합니다.
+ */
+
+import { render, screen, within, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { SnackbarProvider } from 'notistack';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import App from '../../App';
+import { server } from '../../setupTests';
+import { Event } from '../../types';
+
+// Mock events for testing
+const mockRepeatingEventWithEnd: Event = {
+ id: 'repeat-with-end-1',
+ title: '매주 회의',
+ date: '2025-10-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '종료 날짜가 있는 반복 일정',
+ location: '회의실',
+ category: '업무',
+ repeat: {
+ type: 'weekly',
+ interval: 1,
+ endDate: '2025-10-31',
+ },
+ notificationTime: 10,
+};
+
+const mockRepeatingEventNoEnd: Event = {
+ id: 'repeat-no-end-1',
+ title: '매일 운동',
+ date: '2025-10-01',
+ startTime: '07:00',
+ endTime: '08:00',
+ description: '종료 날짜 없음',
+ location: '헬스장',
+ category: '개인',
+ repeat: {
+ type: 'daily',
+ interval: 1,
+ },
+ notificationTime: 10,
+};
+
+const renderApp = () => {
+ return render(
+
+
+
+ );
+};
+
+describe('FEATURE3: 반복 일정 종료 조건 (Epic: 반복 일정 종료 관리)', () => {
+ beforeEach(() => {
+ // MSW 핸들러를 사용하여 Mock 데이터 설정
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({
+ events: [mockRepeatingEventWithEnd, mockRepeatingEventNoEnd],
+ });
+ })
+ );
+ });
+
+ // ----- Story 1: 반복 종료 조건 설정 -----
+ describe('Story 1: 반복 종료 조건 설정', () => {
+ it('TC-3-1-1 - 반복 체크 시 종료 날짜 입력 필드가 표시된다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 먼저 "반복 일정" 체크박스 클릭
+ const repeatCheckbox = screen.getByRole('checkbox', { name: '반복 일정' });
+ await userEvent.click(repeatCheckbox);
+
+ // Assert: 종료 날짜 입력 필드가 표시됨
+ // "반복 종료일" 텍스트가 있는지 확인 (FormLabel)
+ expect(screen.getByText('반복 종료일')).toBeInTheDocument();
+
+ // date type input이 2개 이상 있는지 확인 (날짜 필드 + 반복 종료일)
+ const dateInputs = screen.getAllByDisplayValue('');
+ const dateTypeInputs = dateInputs.filter((input) => input.getAttribute('type') === 'date');
+ expect(dateTypeInputs.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('TC-3-1-2 - 반복 유형 "반복 안함" 선택 시 종료 날짜 필드가 숨겨진다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // 먼저 "반복 일정" 체크박스 클릭
+ const repeatCheckbox = screen.getByRole('checkbox', { name: '반복 일정' });
+ await userEvent.click(repeatCheckbox);
+
+ // 반복 유형 필드가 표시되는지 확인
+ expect(screen.getByText('반복 종료일')).toBeInTheDocument();
+
+ // Act: "반복 일정" 체크박스를 다시 클릭하여 해제
+ await userEvent.click(repeatCheckbox);
+
+ // Assert: 종료 날짜 입력 필드가 숨겨짐
+ expect(screen.queryByText('반복 종료일')).not.toBeInTheDocument();
+ });
+
+ it(
+ 'TC-3-1-3 - 종료 날짜를 입력하고 저장하면 해당 날짜까지만 반복 일정이 생성된다',
+ async () => {
+ // Arrange: 앱 렌더링 및 MSW 설정
+ let postedEvents: Event[] = [];
+ server.use(
+ http.get('/api/events', () => {
+ // 겹침을 피하기 위해 초기 이벤트를 빈 배열로 설정
+ return HttpResponse.json({ events: [] });
+ }),
+ http.post('/api/events-list', async ({ request }) => {
+ const body = (await request.json()) as { events: Event[] };
+ postedEvents = body.events;
+ return HttpResponse.json({ success: true }, { status: 201 });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일정 추가 폼에 입력
+ await userEvent.type(screen.getByLabelText('제목'), '매일 회의');
+ await userEvent.type(screen.getByLabelText('날짜'), '2025-10-01');
+ await userEvent.type(screen.getByLabelText('시작 시간'), '10:00');
+ await userEvent.type(screen.getByLabelText('종료 시간'), '11:00');
+
+ // 반복 일정 체크박스 클릭
+ await userEvent.click(screen.getByRole('checkbox', { name: '반복 일정' }));
+
+ // 반복 유형 필드가 나타날 때까지 대기
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+ await userEvent.selectOptions(screen.getByLabelText('반복 유형'), 'daily');
+
+ // 종료 날짜 입력
+ const endDateInput = screen.getByTestId('repeat-end-date-input') as HTMLInputElement;
+ await userEvent.clear(endDateInput);
+ await userEvent.type(endDateInput, '2025-10-05');
+
+ // 종료 날짜가 제대로 입력되었는지 확인
+ await waitFor(() => {
+ expect(endDateInput.value).toBe('2025-10-05');
+ });
+
+ // 저장 버튼 클릭
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|저장|수정)/i });
+ await userEvent.click(saveButton);
+
+ // Assert: 생성된 일정이 5개 (10/1~10/5)
+ // API 호출이 완료될 때까지 대기
+ // 에러 메시지가 표시되면 실패
+ await waitFor(
+ () => {
+ expect(postedEvents).toHaveLength(5);
+ },
+ { timeout: 10000 }
+ );
+ // 스낵바는 선택적으로 확인 (저장 성공은 postedEvents로 확인됨)
+ const snackbar = screen.queryByText('일정이 추가되었습니다');
+ if (snackbar) {
+ expect(snackbar).toBeInTheDocument();
+ }
+ expect(postedEvents[0].date).toBe('2025-10-01');
+ expect(postedEvents[4].date).toBe('2025-10-05');
+ },
+ { timeout: 15000 }
+ );
+
+ it(
+ 'TC-3-1-4 - 종료 날짜 미입력 시 기본값(2025-12-31)까지 생성된다',
+ async () => {
+ // Arrange: 앱 렌더링 및 MSW 설정
+ let postedEvents: Event[] = [];
+ server.use(
+ http.get('/api/events', () => {
+ // 겹침을 피하기 위해 초기 이벤트를 빈 배열로 설정
+ return HttpResponse.json({ events: [] });
+ }),
+ http.post('/api/events-list', async ({ request }) => {
+ const body = (await request.json()) as { events: Event[] };
+ postedEvents = body.events;
+ return HttpResponse.json({ success: true }, { status: 201 });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일정 추가 폼에 입력 (종료 날짜 제외)
+ await userEvent.type(screen.getByLabelText('제목'), '매주 회의');
+ await userEvent.type(screen.getByLabelText('날짜'), '2025-10-01');
+ await userEvent.type(screen.getByLabelText('시작 시간'), '10:00');
+ await userEvent.type(screen.getByLabelText('종료 시간'), '11:00');
+
+ // 반복 일정 체크박스 클릭
+ await userEvent.click(screen.getByRole('checkbox', { name: '반복 일정' }));
+
+ // 반복 유형 필드가 나타날 때까지 대기
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+ await userEvent.selectOptions(screen.getByLabelText('반복 유형'), 'weekly');
+
+ // 저장 버튼 클릭
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|저장|수정)/i });
+ await userEvent.click(saveButton);
+
+ // 겹침 다이얼로그 처리 (있으면 계속 진행)
+ await waitFor(
+ async () => {
+ const overlapDialog = screen.queryByText('일정 겹침 경고');
+ if (overlapDialog) {
+ const continueButton = screen.getByRole('button', { name: '계속 진행' });
+ await userEvent.click(continueButton);
+ }
+ return !screen.queryByText('일정 겹침 경고');
+ },
+ { timeout: 3000 }
+ );
+
+ // Assert: 마지막 일정이 2025-12-31 이전, 2026-01-01 이후는 없음
+ await waitFor(
+ () => {
+ expect(postedEvents.length).toBeGreaterThan(0);
+ },
+ { timeout: 10000 }
+ );
+ // 스낵바는 선택적으로 확인 (저장 성공은 postedEvents로 확인됨)
+ const snackbar = screen.queryByText('일정이 추가되었습니다');
+ if (snackbar) {
+ expect(snackbar).toBeInTheDocument();
+ }
+
+ const lastEvent = postedEvents[postedEvents.length - 1];
+ const lastDate = new Date(lastEvent.date);
+ const maxDate = new Date('2025-12-31');
+ expect(lastDate.getTime()).toBeLessThanOrEqual(maxDate.getTime());
+
+ // 2026년 일정이 없어야 함
+ const hasEvent2026 = postedEvents.some((event) => event.date.startsWith('2026'));
+ expect(hasEvent2026).toBe(false);
+ },
+ { timeout: 15000 }
+ );
+ });
+
+ // ----- Story 2: 반복 종료 조건 검증 -----
+ describe('Story 2: 반복 종료 조건 검증', () => {
+ it(
+ 'TC-3-2-1 - 종료 날짜가 시작 날짜보다 이전이면 에러 메시지가 표시된다',
+ async () => {
+ // Arrange: 앱 렌더링 및 MSW 설정 (API 호출이 없어야 함)
+ let apiCalled = false;
+ server.use(
+ http.post('/api/events-list', () => {
+ apiCalled = true;
+ return HttpResponse.json({ success: true }, { status: 201 });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 잘못된 종료 날짜 입력
+ await userEvent.type(screen.getByLabelText('제목'), '테스트 일정');
+ await userEvent.type(screen.getByLabelText('날짜'), '2025-10-15');
+ await userEvent.type(screen.getByLabelText('시작 시간'), '10:00');
+ await userEvent.type(screen.getByLabelText('종료 시간'), '11:00');
+
+ // 반복 일정 체크박스 클릭
+ await userEvent.click(screen.getByRole('checkbox', { name: '반복 일정' }));
+
+ // 반복 유형 필드가 나타날 때까지 대기
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+ await userEvent.selectOptions(screen.getByLabelText('반복 유형'), 'daily');
+ // 종료 날짜 입력
+ const endDateInput = screen.getByTestId('repeat-end-date-input');
+ await userEvent.click(endDateInput);
+ await userEvent.paste('2025-10-10');
+
+ // 저장 버튼 클릭
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|저장)/i });
+ await userEvent.click(saveButton);
+
+ // Assert: 에러 메시지 표시, API 호출 없음
+ await waitFor(
+ () => {
+ expect(screen.getByText(/종료 날짜는 시작 날짜 이후여야 합니다/i)).toBeInTheDocument();
+ },
+ { timeout: 10000 }
+ );
+ expect(apiCalled).toBe(false);
+ },
+ { timeout: 15000 }
+ );
+
+ it(
+ 'TC-3-2-2 - 반복 일정이 종료 날짜 다음날부터는 생성되지 않는다',
+ async () => {
+ // Arrange: 앱 렌더링 및 MSW 설정
+ let postedEvents: Event[] = [];
+ server.use(
+ http.get('/api/events', () => {
+ // 겹침을 피하기 위해 초기 이벤트를 빈 배열로 설정
+ return HttpResponse.json({ events: [] });
+ }),
+ http.post('/api/events-list', async ({ request }) => {
+ const body = (await request.json()) as { events: Event[] };
+ postedEvents = body.events;
+ return HttpResponse.json({ success: true }, { status: 201 });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일정 추가 (10/1 ~ 10/10)
+ await userEvent.type(screen.getByLabelText('제목'), '매일 미팅');
+ await userEvent.type(screen.getByLabelText('날짜'), '2025-10-01');
+ await userEvent.type(screen.getByLabelText('시작 시간'), '10:00');
+ await userEvent.type(screen.getByLabelText('종료 시간'), '11:00');
+
+ // 반복 일정 체크박스 클릭
+ await userEvent.click(screen.getByRole('checkbox', { name: '반복 일정' }));
+
+ // 반복 유형 필드가 나타날 때까지 대기
+ await waitFor(() => {
+ expect(screen.getByLabelText('반복 유형')).toBeInTheDocument();
+ });
+ await userEvent.selectOptions(screen.getByLabelText('반복 유형'), 'daily');
+ // 종료 날짜 입력
+ const endDateInput = screen.getByTestId('repeat-end-date-input') as HTMLInputElement;
+ await userEvent.clear(endDateInput);
+ await userEvent.type(endDateInput, '2025-10-10');
+
+ // 종료 날짜가 제대로 입력되었는지 확인
+ await waitFor(() => {
+ expect(endDateInput.value).toBe('2025-10-10');
+ });
+
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|저장|수정)/i });
+ await userEvent.click(saveButton);
+
+ // 겹침 다이얼로그 처리 (있으면 계속 진행)
+ await waitFor(
+ async () => {
+ const overlapDialog = screen.queryByText('일정 겹침 경고');
+ if (overlapDialog) {
+ const continueButton = screen.getByRole('button', { name: '계속 진행' });
+ await userEvent.click(continueButton);
+ }
+ return !screen.queryByText('일정 겹침 경고');
+ },
+ { timeout: 3000 }
+ );
+
+ // Assert: 정확히 10개, 마지막 날짜 10/10, 10/11은 없음
+ await waitFor(
+ () => {
+ expect(postedEvents).toHaveLength(10);
+ },
+ { timeout: 10000 }
+ );
+ // 스낵바는 선택적으로 확인 (저장 성공은 postedEvents로 확인됨)
+ const snackbar = screen.queryByText('일정이 추가되었습니다');
+ if (snackbar) {
+ expect(snackbar).toBeInTheDocument();
+ }
+ expect(postedEvents[9].date).toBe('2025-10-10');
+
+ const hasEvent1011 = postedEvents.some((event) => event.date === '2025-10-11');
+ expect(hasEvent1011).toBe(false);
+ },
+ { timeout: 15000 }
+ );
+
+ it('TC-3-2-3 - 종료 날짜가 2025-12-31을 초과하면 2025-12-31까지만 생성된다', async () => {
+ // Arrange: 앱 렌더링 및 MSW 설정
+ let postedEvents: Event[] = [];
+ server.use(
+ http.get('/api/events', () => {
+ // 겹침을 피하기 위해 초기 이벤트를 빈 배열로 설정
+ return HttpResponse.json({ events: [] });
+ }),
+ http.post('/api/events-list', async ({ request }) => {
+ const body = (await request.json()) as { events: Event[] };
+ postedEvents = body.events;
+ return HttpResponse.json({ success: true }, { status: 201 });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 종료 날짜를 2026년으로 설정
+ await userEvent.type(screen.getByLabelText('제목'), '매일 운동');
+ await userEvent.type(screen.getByLabelText('날짜'), '2025-12-01');
+ await userEvent.type(screen.getByLabelText('시작 시간'), '07:00');
+ await userEvent.type(screen.getByLabelText('종료 시간'), '08:00');
+
+ // 반복 일정 체크박스 클릭
+ await userEvent.click(screen.getByRole('checkbox', { name: '반복 일정' }));
+
+ await userEvent.selectOptions(screen.getByLabelText('반복 유형'), 'daily');
+ // 종료 날짜 입력 (2026년으로 설정하여 최대값 테스트)
+ const endDateInput = screen.getByTestId('repeat-end-date-input');
+ await userEvent.click(endDateInput);
+ await userEvent.paste('2026-01-31');
+
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|저장|수정)/i });
+ await userEvent.click(saveButton);
+
+ // Assert: 마지막 일정이 2025-12-31, 총 31개
+ await waitFor(
+ () => {
+ expect(postedEvents).toHaveLength(31); // 12/1 ~ 12/31
+ },
+ { timeout: 10000 }
+ );
+ // 스낵바는 선택적으로 확인 (저장 성공은 postedEvents로 확인됨)
+ const snackbar = screen.queryByText('일정이 추가되었습니다');
+ if (snackbar) {
+ expect(snackbar).toBeInTheDocument();
+ }
+
+ const lastEvent = postedEvents[postedEvents.length - 1];
+ expect(lastEvent.date).toBe('2025-12-31');
+
+ const hasEvent2026 = postedEvents.some((event) => event.date.startsWith('2026'));
+ expect(hasEvent2026).toBe(false);
+ });
+ });
+
+ // ----- Story 3: 종료 날짜 수정 및 표시 -----
+ describe('Story 3: 종료 날짜 수정 및 표시', () => {
+ it('TC-3-3-1 - 반복 일정 수정 시 기존 종료 날짜가 입력 필드에 표시된다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 종료 날짜가 있는 반복 일정 클릭 및 수정 버튼 클릭 (이벤트 리스트 범위로 제한)
+ const list = screen.getByTestId('event-list');
+ const eventTitle = (await within(list).findAllByText(/^매주 회의$/))[0];
+ await userEvent.click(eventTitle);
+
+ const editButton = within(list).getAllByRole('button', { name: /수정/i })[0];
+ await userEvent.click(editButton);
+
+ // Feature4 편집 모드 다이얼로그 처리: 단일 수정 선택 후 닫힘 대기
+ const yesButton = await screen.findByRole('button', { name: /예/i });
+ await userEvent.click(yesButton);
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // Assert: "반복 종료일" 텍스트가 표시됨 (기능 미구현 상태)
+ expect(screen.getByText('반복 종료일')).toBeInTheDocument();
+ });
+
+ it(
+ 'TC-3-3-2 - 종료 날짜를 수정하면 새로운 종료 날짜가 반영된다',
+ async () => {
+ // Arrange: 앱 렌더링 및 MSW 설정 (배치 PUT API 모킹)
+ let updatedEvent: Event | null = null;
+ server.use(
+ http.put('/api/events-list', async ({ request }) => {
+ const body = (await request.json()) as { events: Event[] };
+ // 첫 번째 이벤트를 updatedEvent에 저장
+ if (body.events.length > 0) {
+ updatedEvent = body.events[0];
+ }
+ return HttpResponse.json(body.events);
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 반복 일정 수정 (이벤트 리스트 범위로 제한)
+ const list = screen.getByTestId('event-list');
+ const eventTitle = (await within(list).findAllByText(/^매주 회의$/))[0];
+ await userEvent.click(eventTitle);
+
+ const editButton = within(list).getAllByRole('button', { name: /수정/i })[0];
+ await userEvent.click(editButton);
+
+ // 편집 모드 다이얼로그: 전체 수정 선택 후 닫힘 대기 (endDate 반영 검증)
+ const noButton = await screen.findByRole('button', { name: /아니오/i });
+ await userEvent.click(noButton);
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // 종료 날짜 수정
+ const endDateInput = screen.getByTestId('repeat-end-date-input');
+ await userEvent.clear(endDateInput);
+ await userEvent.paste('2025-11-30');
+
+ // 저장 버튼 클릭
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|수정)/i });
+ await userEvent.click(saveButton);
+
+ // Assert: 수정된 종료 날짜 확인
+ await waitFor(
+ () => {
+ expect(updatedEvent).not.toBeNull();
+ },
+ { timeout: 10000 }
+ );
+ // 스낵바는 선택적으로 확인 (저장 성공은 updatedEvent로 확인됨)
+ const snackbar = screen.queryByText('일정이 수정되었습니다');
+ if (snackbar) {
+ expect(snackbar).toBeInTheDocument();
+ }
+ const updated = (updatedEvent ?? ({} as unknown)) as Event;
+ expect(updated.repeat.endDate).toBe('2025-11-30');
+ },
+ { timeout: 15000 }
+ );
+
+ it('TC-3-3-3 - 일정 목록에서 반복 일정의 종료 날짜 정보가 표시된다', async () => {
+ // Arrange: 앱 렌더링
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일정 목록 확인
+ const eventList = screen.getByTestId('event-list');
+
+ // Assert: 종료 날짜 정보 표시 확인
+ // 우리 UI는 "(종료: YYYY-MM-DD)" 형태로 표시
+ const endDateInfo = await within(eventList).findByText(/\(종료: 2025-10-31\)/i);
+ expect(endDateInfo).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/__tests__/integration/feature4-integration.spec.tsx b/src/__tests__/integration/feature4-integration.spec.tsx
new file mode 100644
index 00000000..638cf3f3
--- /dev/null
+++ b/src/__tests__/integration/feature4-integration.spec.tsx
@@ -0,0 +1,552 @@
+/**
+ * Integration Test: Feature 4 - 반복 일정 수정
+ * Epic: 반복 일정 수정 관리
+ *
+ * 이 테스트는 반복 일정 수정 시 단일/전체 수정 모드 선택 및 동작을 검증합니다.
+ */
+
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { SnackbarProvider } from 'notistack';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import App from '../../App';
+import { server } from '../../setupTests';
+import { Event } from '../../types';
+
+// Mock repeating events (same group)
+const mockRepeatingEvents: Event[] = [
+ {
+ id: 'repeat-mon-1',
+ title: '팀 미팅',
+ date: '2025-10-06', // 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'repeat-mon-2',
+ title: '팀 미팅',
+ date: '2025-10-13', // 다음 주 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'repeat-mon-3',
+ title: '팀 미팅',
+ date: '2025-10-20', // 다다음 주 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+];
+
+const mockNormalEvent: Event = {
+ id: 'normal-1',
+ title: '일반 회의',
+ date: '2025-10-07',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '일반 일정',
+ location: '사무실',
+ category: '업무',
+ repeat: { type: 'none', interval: 1 },
+ notificationTime: 10,
+};
+
+const renderApp = () => {
+ return render(
+
+
+
+ );
+};
+
+/**
+ * Helper function to find edit button for an event in the event list
+ */
+const findEditButton = (eventTitle: string, index: number = 0): HTMLElement => {
+ const eventList = screen.getByTestId('event-list');
+ const eventTitles = within(eventList).getAllByText(eventTitle);
+ const targetEvent = eventTitles[index];
+
+ // The event title is nested inside: Box > Stack > Stack > Typography
+ // We need to go up to the Box level
+ const eventContainer = targetEvent.closest('.MuiBox-root');
+ if (!eventContainer) {
+ throw new Error(`Could not find event container for ${eventTitle}`);
+ }
+
+ const editButton = within(eventContainer as HTMLElement).getByLabelText('수정');
+ return editButton;
+};
+
+describe('FEATURE4: 반복 일정 수정 (Epic: 반복 일정 수정 관리)', () => {
+ beforeEach(() => {
+ // MSW 핸들러를 사용하여 Mock 데이터 설정
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({
+ events: [...mockRepeatingEvents, mockNormalEvent],
+ });
+ })
+ );
+ });
+
+ // ----- Story 1: 단일 반복 일정 수정 -----
+ describe('Story 1: 단일 반복 일정 수정', () => {
+ it('TC-4-1-1: 단일 수정 선택 시 해당 일정만 수정되고 반복 속성이 제거된다', async () => {
+ // Arrange: MSW로 PUT 요청 모킹
+ const updatedEventRef: { current: Event | null } = { current: null };
+ server.use(
+ http.put('/api/events/:id', async ({ params, request }) => {
+ const id = params.id as string;
+ const body = (await request.json()) as Event;
+
+ if (id === 'repeat-mon-1') {
+ updatedEventRef.current = body;
+ }
+
+ return HttpResponse.json(body);
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 첫 번째 반복 일정의 수정 버튼 클릭
+ const editButton = findEditButton('팀 미팅', 0);
+ await userEvent.click(editButton);
+
+ // 다이얼로그에서 "예" (단일 수정) 선택
+ const yesButton = await screen.findByRole('button', { name: /예/i });
+ await userEvent.click(yesButton);
+
+ // 다이얼로그가 닫히고 폼이 채워질 때까지 대기
+ await waitFor(
+ () => {
+ expect(screen.queryByText('해당 일정만 수정하시겠어요?')).not.toBeInTheDocument();
+ },
+ { timeout: 2000 }
+ );
+
+ // 폼이 제대로 채워졌는지 확인
+ await waitFor(
+ () => {
+ const titleInput = screen.getByLabelText('제목') as HTMLInputElement;
+ const startTimeInput = screen.getByLabelText('시작 시간') as HTMLInputElement;
+ const endTimeInput = screen.getByLabelText('종료 시간') as HTMLInputElement;
+ expect(titleInput.value).toBe('팀 미팅');
+ expect(startTimeInput.value).toBe('10:00');
+ expect(endTimeInput.value).toBe('11:00');
+ },
+ { timeout: 3000 }
+ );
+
+ // 제목 수정
+ const titleInput = screen.getByLabelText('제목') as HTMLInputElement;
+ await userEvent.clear(titleInput);
+ await userEvent.type(titleInput, '개인 미팅');
+
+ // 저장
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|수정)/i });
+ await userEvent.click(saveButton);
+
+ // Assert
+ const snackbar = await screen.findByText('일정이 수정되었습니다', {}, { timeout: 4000 });
+ expect(snackbar).toBeInTheDocument();
+ expect(updatedEventRef.current).not.toBeNull();
+ expect(updatedEventRef.current?.title).toBe('개인 미팅');
+ expect(updatedEventRef.current?.repeat.type).toBe('none');
+ }, 10000);
+
+ it('TC-4-1-2: 단일 수정 후 해당 일정의 반복 아이콘이 사라진다', async () => {
+ // Arrange: 단일 수정 완료된 일정 (repeat.type = 'none')
+ const modifiedEvent: Event = {
+ ...mockRepeatingEvents[0],
+ id: 'repeat-mon-1',
+ title: '개인 미팅',
+ repeat: { type: 'none', interval: 1 },
+ };
+
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({
+ events: [modifiedEvent, mockRepeatingEvents[1], mockRepeatingEvents[2]],
+ });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일정 목록 확인
+ const eventList = screen.getByTestId('event-list');
+
+ // Assert: "개인 미팅" 주변에 반복 아이콘 없음
+ const personalMeeting = within(eventList).getByText('개인 미팅');
+ const container = (personalMeeting.closest('[role="button"]') ||
+ personalMeeting.parentElement) as HTMLElement;
+
+ if (container) {
+ const icons = within(container).queryAllByLabelText('반복 일정');
+ expect(icons).toHaveLength(0);
+ }
+
+ // "팀 미팅" (나머지 반복 일정)은 아이콘 유지
+ const teamMeetings = within(eventList).getAllByText('팀 미팅');
+ expect(teamMeetings.length).toBeGreaterThan(0);
+ });
+
+ it('TC-4-1-3: 단일 수정 시 나머지 반복 일정은 유지된다', async () => {
+ // Arrange: PUT 요청 모킹 (1번만 호출되어야 함)
+ const putCalls: string[] = [];
+ server.use(
+ http.put('/api/events/:id', async ({ params, request }) => {
+ const id = params.id as string;
+ putCalls.push(id);
+ const body = (await request.json()) as Event;
+ return HttpResponse.json(body);
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 두 번째 반복 일정의 수정 버튼 클릭
+ const editButton = findEditButton('팀 미팅', 1);
+ await userEvent.click(editButton);
+
+ // "예" 선택
+ const yesButton = await screen.findByRole('button', { name: /예/i });
+ await userEvent.click(yesButton);
+
+ // 다이얼로그가 닫히고 폼이 채워질 때까지 대기
+ await waitFor(
+ () => {
+ expect(screen.queryByText('해당 일정만 수정하시겠어요?')).not.toBeInTheDocument();
+ },
+ { timeout: 2000 }
+ );
+
+ // 폼이 제대로 채워졌는지 확인
+ await waitFor(
+ () => {
+ const titleInput = screen.getByLabelText('제목') as HTMLInputElement;
+ const startTimeInput = screen.getByLabelText('시작 시간') as HTMLInputElement;
+ const endTimeInput = screen.getByLabelText('종료 시간') as HTMLInputElement;
+ expect(titleInput.value).toBe('팀 미팅');
+ expect(startTimeInput.value).toBe('10:00');
+ expect(endTimeInput.value).toBe('11:00');
+ },
+ { timeout: 3000 }
+ );
+
+ // 시간 수정
+ const startTimeInput = screen.getByLabelText('시작 시간') as HTMLInputElement;
+ await userEvent.clear(startTimeInput);
+ await userEvent.type(startTimeInput, '09:00');
+
+ const endTimeInput = screen.getByLabelText('종료 시간') as HTMLInputElement;
+ await userEvent.clear(endTimeInput);
+ await userEvent.type(endTimeInput, '10:00');
+
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|수정)/i });
+ await userEvent.click(saveButton);
+
+ // Assert: PUT 호출 1번만 (두 번째 일정만)
+ await screen.findByText('일정이 수정되었습니다', {}, { timeout: 4000 });
+
+ // PUT 요청 안정화 대기
+ await waitFor(
+ () => {
+ expect(putCalls.length).toBe(1);
+ },
+ { timeout: 1000 }
+ );
+ expect(putCalls[0]).toBe('repeat-mon-2');
+ }, 10000);
+ });
+
+ // ----- Story 2: 전체 반복 일정 수정 -----
+ describe('Story 2: 전체 반복 일정 수정', () => {
+ it('TC-4-2-1: 전체 수정 선택 시 모든 반복 일정이 수정되고 반복 속성이 유지된다', async () => {
+ // Arrange: 배치 PUT 요청 모킹
+ const updatedEvents: { [id: string]: Event } = {};
+ server.use(
+ http.put('/api/events-list', async ({ request }) => {
+ const body = (await request.json()) as { events: Event[] };
+ body.events.forEach((event) => {
+ updatedEvents[event.id] = event;
+ });
+ return HttpResponse.json(body.events);
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 첫 번째 반복 일정의 수정 버튼 클릭
+ const editButton = findEditButton('팀 미팅', 0);
+ await userEvent.click(editButton);
+
+ // "아니오" (전체 수정) 선택
+ const noButton = await screen.findByRole('button', { name: /아니오/i });
+ await userEvent.click(noButton);
+
+ // 다이얼로그가 닫힐 때까지 대기
+ await waitFor(
+ () => {
+ expect(screen.queryByText('해당 일정만 수정하시겠어요?')).not.toBeInTheDocument();
+ },
+ { timeout: 2000 }
+ );
+
+ // 폼이 제대로 채워졌는지 확인
+ await waitFor(
+ () => {
+ const titleInput = screen.getByLabelText('제목') as HTMLInputElement;
+ const startTimeInput = screen.getByLabelText('시작 시간') as HTMLInputElement;
+ const endTimeInput = screen.getByLabelText('종료 시간') as HTMLInputElement;
+ expect(titleInput.value).toBe('팀 미팅');
+ expect(startTimeInput.value).toBe('10:00');
+ expect(endTimeInput.value).toBe('11:00');
+ },
+ { timeout: 3000 }
+ );
+
+ // 제목 수정
+ const titleInput = screen.getByLabelText('제목') as HTMLInputElement;
+ await userEvent.clear(titleInput);
+ await userEvent.type(titleInput, '헬스');
+
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|수정)/i });
+ await userEvent.click(saveButton);
+
+ // Assert: 배치 PUT 호출, 모든 repeat.type = 'weekly', 모든 title = '헬스'
+ await screen.findByText('일정이 수정되었습니다', {}, { timeout: 4000 });
+
+ // 배치 PUT 완료 대기
+ await waitFor(
+ () => {
+ expect(Object.keys(updatedEvents).length).toBe(3);
+ },
+ { timeout: 1500 }
+ );
+
+ for (const id of ['repeat-mon-1', 'repeat-mon-2', 'repeat-mon-3']) {
+ expect(updatedEvents[id]).toBeDefined();
+ expect(updatedEvents[id].title).toBe('헬스');
+ expect(updatedEvents[id].repeat.type).toBe('weekly');
+ }
+ }, 10000);
+
+ it('TC-4-2-2: 전체 수정 후 모든 일정의 반복 아이콘이 유지된다', async () => {
+ // Arrange: 전체 수정 완료된 반복 일정들 (모두 "헬스")
+ const modifiedEvents: Event[] = mockRepeatingEvents.map((event) => ({
+ ...event,
+ title: '헬스',
+ }));
+
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({ events: modifiedEvents });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일정 목록 확인
+ const eventList = screen.getByTestId('event-list');
+ const allIcons = within(eventList).queryAllByLabelText('반복 일정');
+
+ // Assert: 모든 일정에 반복 아이콘 표시
+ expect(allIcons.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('TC-4-2-3: 전체 수정 시 반복 유형이 유지된다', async () => {
+ // Arrange: 매월 반복 일정
+ const monthlyEvents: Event[] = [
+ {
+ id: 'monthly-1',
+ title: '월례 회의',
+ date: '2025-10-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'monthly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'monthly-2',
+ title: '월례 회의',
+ date: '2025-11-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: { type: 'monthly', interval: 1 },
+ notificationTime: 10,
+ },
+ ];
+
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({ events: monthlyEvents });
+ })
+ );
+
+ const updatedEvents: { [id: string]: Event } = {};
+ server.use(
+ http.put('/api/events-list', async ({ request }) => {
+ const body = (await request.json()) as { events: Event[] };
+ body.events.forEach((event) => {
+ updatedEvents[event.id] = event;
+ });
+ return HttpResponse.json(body.events);
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 첫 번째 일정의 수정 버튼 클릭
+ const editButton = findEditButton('월례 회의', 0);
+ await userEvent.click(editButton);
+
+ const noButton = await screen.findByRole('button', { name: /아니오/i });
+ await userEvent.click(noButton);
+
+ // 다이얼로그가 닫힐 때까지 대기
+ await waitFor(
+ () => {
+ expect(screen.queryByText('해당 일정만 수정하시겠어요?')).not.toBeInTheDocument();
+ },
+ { timeout: 2000 }
+ );
+
+ // 폼이 제대로 채워졌는지 확인
+ await waitFor(
+ () => {
+ const titleInput = screen.getByLabelText('제목') as HTMLInputElement;
+ const startTimeInput = screen.getByLabelText('시작 시간') as HTMLInputElement;
+ const endTimeInput = screen.getByLabelText('종료 시간') as HTMLInputElement;
+ expect(titleInput.value).toBe('월례 회의');
+ expect(startTimeInput.value).toBe('09:00');
+ expect(endTimeInput.value).toBe('10:00');
+ },
+ { timeout: 3000 }
+ );
+
+ // 시간만 수정
+ const startTimeInput = screen.getByLabelText('시작 시간') as HTMLInputElement;
+ await userEvent.clear(startTimeInput);
+ await userEvent.type(startTimeInput, '08:00');
+
+ const saveButton = screen.getByRole('button', { name: /일정 (추가|수정)/i });
+ await userEvent.click(saveButton);
+
+ // Assert: 모든 repeat.type = 'monthly'
+ await screen.findByText('일정이 수정되었습니다', {}, { timeout: 4000 });
+
+ // PUT 응답 수집 완료 보장
+ await waitFor(
+ () => {
+ expect(Object.keys(updatedEvents)).toHaveLength(2);
+ for (const id of ['monthly-1', 'monthly-2']) {
+ expect(updatedEvents[id]?.repeat?.type).toBe('monthly');
+ }
+ },
+ { timeout: 1500 }
+ );
+ }, 10000);
+ });
+
+ // ----- Story 3: 수정 확인 다이얼로그 표시 -----
+ describe('Story 3: 수정 확인 다이얼로그 표시', () => {
+ it('TC-4-3-1: 반복 일정 수정 시 다이얼로그가 표시된다', async () => {
+ // Arrange
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 반복 일정의 수정 버튼 클릭
+ const editButton = findEditButton('팀 미팅', 0);
+ await userEvent.click(editButton);
+
+ // Assert: 다이얼로그 표시
+ expect(await screen.findByText('해당 일정만 수정하시겠어요?')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /예/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /아니오/i })).toBeInTheDocument();
+ });
+
+ it('TC-4-3-2: 다이얼로그에서 "예" 선택 시 단일 수정 모드로 진행된다', async () => {
+ // Arrange
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ const editButton = findEditButton('팀 미팅', 0);
+ await userEvent.click(editButton);
+
+ // Act: "예" 선택
+ const yesButton = await screen.findByRole('button', { name: /예/i });
+ await userEvent.click(yesButton);
+
+ // Assert: 다이얼로그 닫힘, 수정 폼 표시
+ await waitFor(() => {
+ expect(screen.queryByText('해당 일정만 수정하시겠어요?')).not.toBeInTheDocument();
+ });
+ expect(screen.getByLabelText('제목')).toBeInTheDocument();
+ });
+
+ it('TC-4-3-3: 다이얼로그에서 "아니오" 선택 시 전체 수정 모드로 진행된다', async () => {
+ // Arrange
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ const editButton = findEditButton('팀 미팅', 0);
+ await userEvent.click(editButton);
+
+ // Act: "아니오" 선택
+ const noButton = await screen.findByRole('button', { name: /아니오/i });
+ await userEvent.click(noButton);
+
+ // Assert: 다이얼로그 닫힘, 수정 폼 표시
+ await waitFor(() => {
+ expect(screen.queryByText('해당 일정만 수정하시겠어요?')).not.toBeInTheDocument();
+ });
+ expect(screen.getByLabelText('제목')).toBeInTheDocument();
+ });
+
+ it('TC-4-3-4: 일반 일정 수정 시 다이얼로그가 표시되지 않는다', async () => {
+ // Arrange
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일반 일정의 수정 버튼 클릭
+ const editButton = findEditButton('일반 회의', 0);
+ await userEvent.click(editButton);
+
+ // Assert: 다이얼로그 표시되지 않음, 바로 수정 폼 표시
+ expect(screen.queryByText('해당 일정만 수정하시겠어요?')).not.toBeInTheDocument();
+ expect(screen.getByLabelText('제목')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/__tests__/integration/feature5-integration.spec.tsx b/src/__tests__/integration/feature5-integration.spec.tsx
new file mode 100644
index 00000000..45cc75e2
--- /dev/null
+++ b/src/__tests__/integration/feature5-integration.spec.tsx
@@ -0,0 +1,560 @@
+/**
+ * Integration Test: Feature 5 - 반복 일정 삭제
+ * Epic: 반복 일정 삭제 관리
+ *
+ * 이 테스트는 반복 일정 삭제 시 단일/전체 삭제 모드 선택 및 동작을 검증합니다.
+ */
+
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { SnackbarProvider } from 'notistack';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import App from '../../App';
+import { server } from '../../setupTests';
+import { Event } from '../../types';
+
+// Mock repeating events for deletion (same group)
+const mockRepeatingEventsForDeletion: Event[] = [
+ {
+ id: 'repeat-del-1',
+ title: '회의',
+ date: '2025-10-06', // 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 회의',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'repeat-del-2',
+ title: '회의',
+ date: '2025-10-13', // 다음 주 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 회의',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'repeat-del-3',
+ title: '회의',
+ date: '2025-10-20', // 다다음 주 월요일
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 회의',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+];
+
+const mockNormalEventForDeletion: Event = {
+ id: 'normal-del-1',
+ title: '일반 일정',
+ date: '2025-10-07',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '일반 일정 설명',
+ location: '장소',
+ category: '개인',
+ repeat: { type: 'none', interval: 1 },
+ notificationTime: 10,
+};
+
+// Mock for exercise repeating events (used in TC-5-2-3)
+const mockExerciseEvents: Event[] = [
+ {
+ id: 'exercise-1',
+ title: '운동',
+ date: '2025-10-02', // 목요일
+ startTime: '18:00',
+ endTime: '19:00',
+ description: '주간 운동',
+ location: '헬스장',
+ category: '개인',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'exercise-2',
+ title: '운동',
+ date: '2025-10-09', // 다음 주 목요일
+ startTime: '18:00',
+ endTime: '19:00',
+ description: '주간 운동',
+ location: '헬스장',
+ category: '개인',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'exercise-3',
+ title: '운동',
+ date: '2025-10-16', // 다다음 주 목요일
+ startTime: '18:00',
+ endTime: '19:00',
+ description: '주간 운동',
+ location: '헬스장',
+ category: '개인',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+];
+
+// Mock for study repeating events (used in TC-5-3-1, TC-5-3-3)
+const mockStudyEvents: Event[] = [
+ {
+ id: 'study-a',
+ title: '스터디',
+ date: '2025-10-01', // 수요일
+ startTime: '19:00',
+ endTime: '21:00',
+ description: '주간 스터디',
+ location: '스터디룸',
+ category: '학습',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'study-b',
+ title: '스터디',
+ date: '2025-10-08', // 다음 주 수요일
+ startTime: '19:00',
+ endTime: '21:00',
+ description: '주간 스터디',
+ location: '스터디룸',
+ category: '학습',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'study-c',
+ title: '스터디',
+ date: '2025-10-15', // 다다음 주 수요일
+ startTime: '19:00',
+ endTime: '21:00',
+ description: '주간 스터디',
+ location: '스터디룸',
+ category: '학습',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+];
+
+// Mock for meeting repeating events (used in TC-5-3-3)
+const mockMeetingEvents: Event[] = [
+ {
+ id: 'meeting-1',
+ title: '미팅',
+ date: '2025-10-03', // 금요일
+ startTime: '15:00',
+ endTime: '16:00',
+ description: '주간 미팅',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'meeting-2',
+ title: '미팅',
+ date: '2025-10-10', // 다음 주 금요일
+ startTime: '15:00',
+ endTime: '16:00',
+ description: '주간 미팅',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'meeting-3',
+ title: '미팅',
+ date: '2025-10-17', // 다다음 주 금요일
+ startTime: '15:00',
+ endTime: '16:00',
+ description: '주간 미팅',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+ {
+ id: 'meeting-4',
+ title: '미팅',
+ date: '2025-10-24', // 3주 후 금요일
+ startTime: '15:00',
+ endTime: '16:00',
+ description: '주간 미팅',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ },
+];
+
+const renderApp = () => {
+ return render(
+
+
+
+ );
+};
+
+/**
+ * Helper function to find delete button for an event in the event list
+ */
+const findDeleteButton = (eventTitle: string, index: number = 0): HTMLElement => {
+ const eventList = screen.getByTestId('event-list');
+ const eventTitles = within(eventList).getAllByText(eventTitle);
+ const targetEvent = eventTitles[index];
+
+ // Find the delete button associated with this event
+ // The event title is nested inside: Box > Stack > Stack > Typography
+ // We need to go up to the Box level
+ const eventContainer = targetEvent.closest('.MuiBox-root');
+ if (!eventContainer) {
+ throw new Error(`Could not find event container for ${eventTitle}`);
+ }
+
+ const deleteButton = within(eventContainer as HTMLElement).getByLabelText('삭제');
+ return deleteButton;
+};
+
+describe('FEATURE5: 반복 일정 삭제 (Epic: 반복 일정 삭제 관리)', () => {
+ // ----- Story 1: 반복 일정 삭제 모드 선택 -----
+ describe('Story 1: 반복 일정 삭제 모드 선택', () => {
+ beforeEach(() => {
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({
+ events: [...mockRepeatingEventsForDeletion, mockNormalEventForDeletion],
+ });
+ })
+ );
+ });
+
+ it('TC-5-1-1: 반복 일정 삭제 클릭 시 확인 다이얼로그가 표시된다', async () => {
+ // Arrange
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 반복 일정의 삭제 버튼 클릭
+ const deleteButton = findDeleteButton('회의', 0);
+ await userEvent.click(deleteButton);
+
+ // Assert: 다이얼로그 표시 확인
+ await waitFor(() => {
+ expect(screen.getByText('해당 일정만 삭제하시겠어요?')).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('button', { name: '예' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '아니오' })).toBeInTheDocument();
+ });
+
+ it('TC-5-1-2: 일반 일정 삭제 시 다이얼로그 없이 즉시 삭제된다', async () => {
+ // Arrange: DELETE API 모킹
+ const deletedIds: string[] = [];
+ server.use(
+ http.delete('/api/events/:id', ({ params }) => {
+ const id = params.id as string;
+ deletedIds.push(id);
+ return HttpResponse.json({ success: true });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 일반 일정의 삭제 버튼 클릭
+ const deleteButton = findDeleteButton('일반 일정', 0);
+ await userEvent.click(deleteButton);
+
+ // Assert: 다이얼로그가 표시되지 않음
+ expect(screen.queryByText('해당 일정만 삭제하시겠어요?')).not.toBeInTheDocument();
+
+ // Assert: DELETE API 즉시 호출
+ await waitFor(() => {
+ expect(deletedIds).toContain('normal-del-1');
+ });
+
+ // Assert: 스낵바 메시지 표시
+ await waitFor(() => {
+ expect(screen.getByText('일정이 삭제되었습니다.')).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ----- Story 2: 단일 일정 삭제 -----
+ describe('Story 2: 단일 일정 삭제', () => {
+ beforeEach(() => {
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({
+ events: mockRepeatingEventsForDeletion,
+ });
+ })
+ );
+ });
+
+ it('TC-5-2-1: 다이얼로그에서 "예" 선택 시 해당 일정만 삭제된다', async () => {
+ // Arrange: DELETE API 모킹
+ const deletedIds: string[] = [];
+ server.use(
+ http.delete('/api/events/:id', ({ params }) => {
+ const id = params.id as string;
+ deletedIds.push(id);
+ return HttpResponse.json({ success: true });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 두 번째 반복 일정의 삭제 버튼 클릭
+ const deleteButton = findDeleteButton('회의', 1);
+ await userEvent.click(deleteButton);
+
+ // Assert: 다이얼로그 표시
+ await waitFor(() => {
+ expect(screen.getByText('해당 일정만 삭제하시겠어요?')).toBeInTheDocument();
+ });
+
+ // Act: "예" 버튼 클릭 (단일 삭제)
+ const yesButton = screen.getByRole('button', { name: '예' });
+ await userEvent.click(yesButton);
+
+ // Assert: DELETE API 호출 횟수 = 1
+ await waitFor(() => {
+ expect(deletedIds.length).toBe(1);
+ });
+
+ // Assert: 특정 ID만 삭제됨
+ expect(deletedIds).toContain('repeat-del-2');
+
+ // Assert: 스낵바 메시지 표시
+ await waitFor(() => {
+ expect(screen.getByText('일정이 삭제되었습니다.')).toBeInTheDocument();
+ });
+ });
+
+ it('TC-5-2-2: 단일 삭제 후 삭제 성공 메시지가 표시된다', async () => {
+ // Arrange: DELETE API 모킹
+ server.use(
+ http.delete('/api/events/:id', () => {
+ return HttpResponse.json({ success: true });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 첫 번째 반복 일정 삭제 (단일 삭제)
+ const deleteButton = findDeleteButton('회의', 0);
+ await userEvent.click(deleteButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('해당 일정만 삭제하시겠어요?')).toBeInTheDocument();
+ });
+
+ const yesButton = screen.getByRole('button', { name: '예' });
+ await userEvent.click(yesButton);
+
+ // Assert: 스낵바 메시지 표시
+ await waitFor(() => {
+ expect(screen.getByText('일정이 삭제되었습니다.')).toBeInTheDocument();
+ });
+ });
+
+ it('TC-5-2-3: 단일 삭제 후 캘린더에서 해당 일정만 사라진다', async () => {
+ // Arrange: GET과 DELETE API 모킹
+ let eventsData = [...mockExerciseEvents];
+
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({ events: eventsData });
+ }),
+ http.delete('/api/events/:id', ({ params }) => {
+ const id = params.id as string;
+ eventsData = eventsData.filter((e) => e.id !== id);
+ return HttpResponse.json({ success: true });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Assert: 초기 상태 확인 (이벤트 리스트 기준으로 3개의 "운동" 일정)
+ const listContainer = screen.getByTestId('event-list');
+ const initialExerciseEvents = within(listContainer).getAllByText(/^운동$/);
+ expect(initialExerciseEvents.length).toBeGreaterThanOrEqual(3);
+
+ // Act: 첫 번째 "운동" 일정 삭제 (단일 삭제)
+ const deleteButton = findDeleteButton('운동', 0);
+ await userEvent.click(deleteButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('해당 일정만 삭제하시겠어요?')).toBeInTheDocument();
+ });
+
+ const yesButton = screen.getByRole('button', { name: '예' });
+ await userEvent.click(yesButton);
+
+ // Wait for deletion to complete and calendar to re-render
+ await waitFor(() => {
+ expect(screen.getByText('일정이 삭제되었습니다.')).toBeInTheDocument();
+ });
+
+ // Assert: "운동" 일정이 이벤트 리스트에서 2개만 남음
+ await waitFor(() => {
+ const remainingExerciseEvents = within(listContainer).getAllByText(/^운동$/);
+ expect(remainingExerciseEvents.length).toBe(2);
+ });
+ });
+ });
+
+ // ----- Story 3: 전체 반복 일정 삭제 -----
+ describe('Story 3: 전체 반복 일정 삭제', () => {
+ it('TC-5-3-1: 다이얼로그에서 "아니오" 선택 시 전체 반복 일정이 삭제된다', async () => {
+ // Arrange: 배치 DELETE API 모킹
+ let eventsData = [...mockStudyEvents];
+ const deletedIds: string[] = [];
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({ events: eventsData });
+ }),
+ http.delete('/api/events-list', async ({ request }) => {
+ const body = (await request.json()) as { eventIds: string[] };
+ deletedIds.push(...body.eventIds);
+ eventsData = eventsData.filter((e) => !body.eventIds.includes(e.id));
+ return new HttpResponse(null, { status: 204 });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: 첫 번째 "스터디" 일정의 삭제 버튼 클릭
+ const deleteButton = findDeleteButton('스터디', 0);
+ await userEvent.click(deleteButton);
+
+ // Assert: 다이얼로그 표시
+ await waitFor(() => {
+ expect(screen.getByText('해당 일정만 삭제하시겠어요?')).toBeInTheDocument();
+ });
+
+ // Act: "아니오" 버튼 클릭 (전체 삭제)
+ const noButton = screen.getByRole('button', { name: '아니오' });
+ await userEvent.click(noButton);
+
+ // Assert: DELETE API 호출 시 삭제된 ID 수 = 3 (전체 그룹)
+ await waitFor(
+ () => {
+ expect(deletedIds.length).toBe(3);
+ },
+ { timeout: 3000 }
+ );
+
+ // Assert: 모든 ID가 삭제됨
+ expect(deletedIds).toContain('study-a');
+ expect(deletedIds).toContain('study-b');
+ expect(deletedIds).toContain('study-c');
+
+ // Assert: 스낵바 메시지 표시
+ await waitFor(() => {
+ expect(screen.getByText(/일정.*개가 삭제되었습니다/i)).toBeInTheDocument();
+ });
+ });
+
+ it('TC-5-3-2: 전체 삭제 후 삭제 성공 메시지가 표시된다', async () => {
+ // Arrange: 배치 DELETE API 모킹
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({ events: mockStudyEvents });
+ }),
+ http.delete('/api/events-list', () => {
+ return new HttpResponse(null, { status: 204 });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Act: "스터디" 일정 전체 삭제
+ const deleteButton = findDeleteButton('스터디', 0);
+ await userEvent.click(deleteButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('해당 일정만 삭제하시겠어요?')).toBeInTheDocument();
+ });
+
+ const noButton = screen.getByRole('button', { name: '아니오' });
+ await userEvent.click(noButton);
+
+ // Assert: 스낵바 메시지 표시
+ await waitFor(
+ () => {
+ expect(screen.getByText(/일정.*개가 삭제되었습니다/i)).toBeInTheDocument();
+ },
+ { timeout: 3000 }
+ );
+ });
+
+ it('TC-5-3-3: 전체 삭제 후 캘린더에서 모든 반복 일정이 사라진다', async () => {
+ // Arrange: GET과 배치 DELETE API 모킹
+ let eventsData = [...mockMeetingEvents];
+
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({ events: eventsData });
+ }),
+ http.delete('/api/events-list', async ({ request }) => {
+ const body = (await request.json()) as { eventIds: string[] };
+ eventsData = eventsData.filter((e) => !body.eventIds.includes(e.id));
+ return new HttpResponse(null, { status: 204 });
+ })
+ );
+
+ renderApp();
+ await screen.findByText('일정 로딩 완료!');
+
+ // Assert: 초기 상태 확인 (4개의 "미팅" 일정)
+ const initialMeetingEvents = screen.getAllByText('미팅');
+ expect(initialMeetingEvents.length).toBeGreaterThanOrEqual(4);
+
+ // Act: 아무 "미팅" 일정 선택하여 전체 삭제
+ const deleteButton = findDeleteButton('미팅', 0);
+ await userEvent.click(deleteButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('해당 일정만 삭제하시겠어요?')).toBeInTheDocument();
+ });
+
+ const noButton = screen.getByRole('button', { name: '아니오' });
+ await userEvent.click(noButton);
+
+ // Wait for deletion to complete and calendar to re-render
+ await waitFor(
+ () => {
+ expect(screen.getByText(/일정.*개가 삭제되었습니다/i)).toBeInTheDocument();
+ },
+ { timeout: 3000 }
+ );
+
+ // Assert: "미팅" 일정이 캘린더에서 완전히 사라짐
+ await waitFor(
+ () => {
+ expect(screen.queryByText('미팅')).not.toBeInTheDocument();
+ },
+ { timeout: 3000 }
+ );
+ });
+ });
+});
diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/integration/medium.integration.spec.tsx
similarity index 93%
rename from src/__tests__/medium.integration.spec.tsx
rename to src/__tests__/integration/medium.integration.spec.tsx
index 788dae14..ebc0c2a8 100644
--- a/src/__tests__/medium.integration.spec.tsx
+++ b/src/__tests__/integration/medium.integration.spec.tsx
@@ -10,10 +10,10 @@ import {
setupMockHandlerCreation,
setupMockHandlerDeletion,
setupMockHandlerUpdating,
-} from '../__mocks__/handlersUtils';
-import App from '../App';
-import { server } from '../setupTests';
-import { Event } from '../types';
+} from '../../__mocks__/handlersUtils';
+import App from '../../App';
+import { server } from '../../setupTests';
+import { Event } from '../../types';
const theme = createTheme();
@@ -84,7 +84,7 @@ describe('일정 CRUD 및 기본 기능', () => {
setupMockHandlerUpdating();
- await user.click(await screen.findByLabelText('Edit event'));
+ await user.click(await screen.findByLabelText('수정'));
await user.clear(screen.getByLabelText('제목'));
await user.type(screen.getByLabelText('제목'), '수정된 회의');
@@ -106,7 +106,7 @@ describe('일정 CRUD 및 기본 기능', () => {
expect(await eventList.findByText('삭제할 이벤트')).toBeInTheDocument();
// 삭제 버튼 클릭
- const allDeleteButton = await screen.findAllByLabelText('Delete event');
+ const allDeleteButton = await screen.findAllByLabelText('삭제');
await user.click(allDeleteButton[0]);
expect(eventList.queryByText('삭제할 이벤트')).not.toBeInTheDocument();
@@ -118,8 +118,8 @@ describe('일정 뷰', () => {
// ! 현재 시스템 시간 2025-10-01
const { user } = setup();
- await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox'));
- await user.click(screen.getByRole('option', { name: 'week-option' }));
+ const viewSelect = within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox');
+ await user.selectOptions(viewSelect, 'week');
// ! 일정 로딩 완료 후 테스트
await screen.findByText('일정 로딩 완료!');
@@ -142,10 +142,10 @@ describe('일정 뷰', () => {
category: '업무',
});
- await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox'));
- await user.click(screen.getByRole('option', { name: 'week-option' }));
+ const viewSelect = within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox');
+ await user.selectOptions(viewSelect, 'week');
- const weekView = within(screen.getByTestId('week-view'));
+ const weekView = within(await screen.findByTestId('week-view'));
expect(weekView.getByText('이번주 팀 회의')).toBeInTheDocument();
});
@@ -307,7 +307,7 @@ describe('일정 충돌', () => {
const { user } = setup();
- const editButton = (await screen.findAllByLabelText('Edit event'))[1];
+ const editButton = (await screen.findAllByLabelText('수정'))[1];
await user.click(editButton);
// 시간 수정하여 다른 일정과 충돌 발생
diff --git a/src/__tests__/unit/eventTypeChecker.spec.ts b/src/__tests__/unit/eventTypeChecker.spec.ts
new file mode 100644
index 00000000..5183ff69
--- /dev/null
+++ b/src/__tests__/unit/eventTypeChecker.spec.ts
@@ -0,0 +1,120 @@
+/**
+ * FEATURE2 단위 테스트: 반복 일정 판별 로직
+ * 대상: isRepeatingEvent 함수
+ *
+ * 이 테스트는 이벤트가 반복 일정인지 일반 일정인지 판별하는
+ * 순수 함수의 정확성을 검증합니다.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import type { Event } from '../../types';
+import { isRepeatingEvent } from '../../utils/eventTypeChecker';
+
+// [Refactored] 타입 단언을 위한 헬퍼 함수 추가
+function createPartialEvent(partial: Record): Event {
+ return partial as unknown as Event;
+}
+
+describe('isRepeatingEvent', () => {
+ describe('정상 케이스: 반복 유형 판별', () => {
+ // [Refactored] AAA 주석 제거, 헬퍼 함수 사용으로 간결성 개선
+ it('TC-U2-1-1: daily 반복 일정을 true로 판별', () => {
+ const event = createPartialEvent({
+ id: '1',
+ title: '매일 회의',
+ repeat: { type: 'daily', interval: 1 },
+ });
+
+ const result = isRepeatingEvent(event);
+
+ expect(result).toBe(true);
+ });
+
+ it('TC-U2-1-2: weekly 반복 일정을 true로 판별', () => {
+ const event = createPartialEvent({
+ id: '2',
+ title: '매주 회의',
+ repeat: { type: 'weekly', interval: 1 },
+ });
+
+ const result = isRepeatingEvent(event);
+
+ expect(result).toBe(true);
+ });
+
+ it('TC-U2-1-3: monthly 반복 일정을 true로 판별', () => {
+ const event = createPartialEvent({
+ id: '3',
+ title: '매월 보고',
+ repeat: { type: 'monthly', interval: 1 },
+ });
+
+ const result = isRepeatingEvent(event);
+
+ expect(result).toBe(true);
+ });
+
+ it('TC-U2-1-4: yearly 반복 일정을 true로 판별', () => {
+ const event = createPartialEvent({
+ id: '4',
+ title: '연간 평가',
+ repeat: { type: 'yearly', interval: 1 },
+ });
+
+ const result = isRepeatingEvent(event);
+
+ expect(result).toBe(true);
+ });
+
+ it('TC-U2-1-5: 일반 일정(none)을 false로 판별', () => {
+ const event = createPartialEvent({
+ id: '5',
+ title: '일반 회의',
+ repeat: { type: 'none', interval: 1 },
+ });
+
+ const result = isRepeatingEvent(event);
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('엣지 케이스: 안전한 처리', () => {
+ // [Refactored] AAA 주석 제거, 헬퍼 함수 사용으로 간결성 개선
+ it('TC-U2-1-6: repeat 속성 없을 때 false 반환', () => {
+ const event = createPartialEvent({
+ id: '6',
+ title: '속성 없음',
+ });
+
+ const result = isRepeatingEvent(event);
+
+ expect(result).toBe(false);
+ });
+
+ it('TC-U2-1-7: repeat.type 없을 때 false 반환', () => {
+ const event = createPartialEvent({
+ id: '7',
+ title: '불완전한 객체',
+ repeat: {},
+ });
+
+ const result = isRepeatingEvent(event);
+
+ expect(result).toBe(false);
+ });
+
+ it('TC-U2-1-8: null 입력 시 false 반환', () => {
+ const result = isRepeatingEvent(null);
+
+ expect(result).toBe(false);
+ });
+
+ it('TC-U2-1-9: undefined 입력 시 false 반환', () => {
+ const result = isRepeatingEvent(undefined);
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/src/__tests__/unit/eventUpdateUtils.spec.ts b/src/__tests__/unit/eventUpdateUtils.spec.ts
new file mode 100644
index 00000000..c3905c27
--- /dev/null
+++ b/src/__tests__/unit/eventUpdateUtils.spec.ts
@@ -0,0 +1,146 @@
+/**
+ * Unit Test: eventUpdateUtils
+ *
+ * 이벤트 수정 적용 로직을 테스트합니다.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import type { Event } from '../../types';
+// TODO: Stage 7에서 구현 예정
+import { applyEventUpdate } from '../../utils/eventUpdateUtils';
+
+describe('eventUpdateUtils', () => {
+ const createMockEvent = (overrides?: Partial): Event => ({
+ id: 'event-1',
+ title: '팀 미팅',
+ date: '2025-10-06',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ ...overrides,
+ });
+
+ describe('applyEventUpdate', () => {
+ it('TC-A-1: mode="single", 제목 수정 시 repeat.type="none"으로 변경된다', () => {
+ // Arrange
+ const event = createMockEvent({ repeat: { type: 'weekly', interval: 1 } });
+ const updates = { title: '개인 미팅' };
+
+ // Act
+ const result = applyEventUpdate(event, updates, 'single');
+
+ // Assert
+ expect(result.title).toBe('개인 미팅');
+ expect(result.repeat.type).toBe('none');
+ });
+
+ it('TC-A-2: mode="all", 제목 수정 시 repeat.type이 유지된다', () => {
+ // Arrange
+ const event = createMockEvent({ repeat: { type: 'weekly', interval: 1 } });
+ const updates = { title: '헬스' };
+
+ // Act
+ const result = applyEventUpdate(event, updates, 'all');
+
+ // Assert
+ expect(result.title).toBe('헬스');
+ expect(result.repeat.type).toBe('weekly');
+ });
+
+ it('TC-A-3: mode="single", 여러 필드 수정 시 repeat.type="none"으로 변경된다', () => {
+ // Arrange
+ const event = createMockEvent({
+ title: '회의',
+ startTime: '10:00',
+ repeat: { type: 'daily', interval: 1 },
+ });
+ const updates = { title: '미팅', startTime: '11:00' };
+
+ // Act
+ const result = applyEventUpdate(event, updates, 'single');
+
+ // Assert
+ expect(result.title).toBe('미팅');
+ expect(result.startTime).toBe('11:00');
+ expect(result.repeat.type).toBe('none');
+ });
+
+ it('TC-A-4: mode="all", 시간 수정 시 repeat.type이 유지된다', () => {
+ // Arrange
+ const event = createMockEvent({
+ startTime: '09:00',
+ repeat: { type: 'monthly', interval: 1 },
+ });
+ const updates = { startTime: '10:00' };
+
+ // Act
+ const result = applyEventUpdate(event, updates, 'all');
+
+ // Assert
+ expect(result.startTime).toBe('10:00');
+ expect(result.repeat.type).toBe('monthly');
+ });
+
+ it('TC-A-5: 일반 일정(repeat.type="none")은 mode 무관하게 "none" 유지', () => {
+ // Arrange
+ const event = createMockEvent({
+ title: '일반',
+ repeat: { type: 'none', interval: 1 },
+ });
+ const updates = { title: '수정' };
+
+ // Act (single 모드)
+ const resultSingle = applyEventUpdate(event, updates, 'single');
+
+ // Assert
+ expect(resultSingle.title).toBe('수정');
+ expect(resultSingle.repeat.type).toBe('none');
+
+ // Act (all 모드)
+ const resultAll = applyEventUpdate(event, updates, 'all');
+
+ // Assert
+ expect(resultAll.title).toBe('수정');
+ expect(resultAll.repeat.type).toBe('none');
+ });
+
+ it('TC-A-6: updates가 빈 객체면 원본 이벤트 그대로 반환된다', () => {
+ // Arrange
+ const event = createMockEvent({
+ title: '회의',
+ repeat: { type: 'weekly', interval: 1 },
+ });
+ const updates = {};
+
+ // Act
+ const result = applyEventUpdate(event, updates, 'single');
+
+ // Assert
+ expect(result.title).toBe('회의');
+ expect(result.repeat.type).toBe('weekly'); // 수정 없으므로 유지
+ });
+
+ it('TC-A-7: 수정되지 않은 필드는 원본 값을 유지한다', () => {
+ // Arrange
+ const event = createMockEvent({
+ title: '회의',
+ date: '2025-10-01',
+ startTime: '10:00',
+ });
+ const updates = { title: '미팅' };
+
+ // Act
+ const result = applyEventUpdate(event, updates, 'all');
+
+ // Assert
+ expect(result.title).toBe('미팅');
+ expect(result.date).toBe('2025-10-01');
+ expect(result.startTime).toBe('10:00');
+ });
+ });
+});
diff --git a/src/__tests__/unit/repeatDateUtils.endDate.spec.ts b/src/__tests__/unit/repeatDateUtils.endDate.spec.ts
new file mode 100644
index 00000000..4255c9ca
--- /dev/null
+++ b/src/__tests__/unit/repeatDateUtils.endDate.spec.ts
@@ -0,0 +1,102 @@
+/**
+ * Unit Test: repeatDateUtils - getRepeatEndDate
+ *
+ * 반복 일정의 종료 날짜 기본값 및 최대값 처리를 테스트합니다.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+// TODO: Stage 7에서 구현 예정
+import { getRepeatEndDate } from '../../utils/repeatDateUtils';
+
+describe('repeatDateUtils', () => {
+ describe('getRepeatEndDate', () => {
+ it('TC-G-1: undefined면 기본값 반환', () => {
+ // Arrange
+ const endDate = undefined;
+
+ // Act
+ const result = getRepeatEndDate(endDate);
+
+ // Assert
+ expect(result).toBe('2025-12-31');
+ });
+
+ it('TC-G-2: null이면 기본값 반환', () => {
+ // Arrange
+ const endDate = null as unknown as undefined;
+
+ // Act
+ const result = getRepeatEndDate(endDate);
+
+ // Assert
+ expect(result).toBe('2025-12-31');
+ });
+
+ it('TC-G-3: 빈 문자열이면 기본값 반환', () => {
+ // Arrange
+ const endDate = '';
+
+ // Act
+ const result = getRepeatEndDate(endDate);
+
+ // Assert
+ expect(result).toBe('2025-12-31');
+ });
+
+ it('TC-G-4: 유효한 날짜면 그대로 반환', () => {
+ // Arrange
+ const endDate = '2025-10-31';
+
+ // Act
+ const result = getRepeatEndDate(endDate);
+
+ // Assert
+ expect(result).toBe('2025-10-31');
+ });
+
+ it('TC-G-5: 최대값(2025-12-31)이면 그대로 반환', () => {
+ // Arrange
+ const endDate = '2025-12-31';
+
+ // Act
+ const result = getRepeatEndDate(endDate);
+
+ // Assert
+ expect(result).toBe('2025-12-31');
+ });
+
+ it('TC-G-6: 최대값 초과하면 최대값 반환', () => {
+ // Arrange
+ const endDate = '2026-01-01';
+
+ // Act
+ const result = getRepeatEndDate(endDate);
+
+ // Assert
+ expect(result).toBe('2025-12-31');
+ });
+
+ it('TC-G-7: 훨씬 미래 날짜도 최대값으로 제한', () => {
+ // Arrange
+ const endDate = '2027-12-31';
+
+ // Act
+ const result = getRepeatEndDate(endDate);
+
+ // Assert
+ expect(result).toBe('2025-12-31');
+ });
+
+ it('TC-G-8: 과거 날짜는 그대로 반환 (시작 날짜 검증은 별도)', () => {
+ // Arrange
+ const endDate = '2024-12-31';
+
+ // Act
+ const result = getRepeatEndDate(endDate);
+
+ // Assert
+ expect(result).toBe('2024-12-31');
+ });
+ });
+});
diff --git a/src/__tests__/unit/repeatDateUtils.spec.ts b/src/__tests__/unit/repeatDateUtils.spec.ts
new file mode 100644
index 00000000..138f7d99
--- /dev/null
+++ b/src/__tests__/unit/repeatDateUtils.spec.ts
@@ -0,0 +1,125 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+ addDays,
+ addMonths,
+ addWeeks,
+ addYears,
+ getLastDayOfMonth,
+ isLeapYear,
+ isValidDateInMonth,
+} from '../../utils/repeatDateUtils';
+
+describe('RepeatDateUtils', () => {
+ describe('isLeapYear', () => {
+ it('isLeapYear - 윤년을 올바르게 판별한다', () => {
+ expect(isLeapYear(2024)).toBe(true);
+ expect(isLeapYear(2020)).toBe(true);
+ expect(isLeapYear(2000)).toBe(true);
+ });
+
+ it('isLeapYear - 평년을 올바르게 판별한다', () => {
+ expect(isLeapYear(2023)).toBe(false);
+ expect(isLeapYear(2025)).toBe(false);
+ expect(isLeapYear(1900)).toBe(false);
+ });
+ });
+
+ describe('getLastDayOfMonth', () => {
+ it('getLastDayOfMonth - 윤년 2월의 마지막 날은 29일이다', () => {
+ expect(getLastDayOfMonth(2024, 2)).toBe(29);
+ });
+
+ it('getLastDayOfMonth - 평년 2월의 마지막 날은 28일이다', () => {
+ expect(getLastDayOfMonth(2023, 2)).toBe(28);
+ expect(getLastDayOfMonth(2025, 2)).toBe(28);
+ });
+
+ it('getLastDayOfMonth - 31일까지 있는 월을 올바르게 반환한다', () => {
+ expect(getLastDayOfMonth(2024, 1)).toBe(31);
+ expect(getLastDayOfMonth(2024, 3)).toBe(31);
+ expect(getLastDayOfMonth(2024, 5)).toBe(31);
+ expect(getLastDayOfMonth(2024, 7)).toBe(31);
+ expect(getLastDayOfMonth(2024, 8)).toBe(31);
+ expect(getLastDayOfMonth(2024, 10)).toBe(31);
+ expect(getLastDayOfMonth(2024, 12)).toBe(31);
+ });
+
+ it('getLastDayOfMonth - 30일까지 있는 월을 올바르게 반환한다', () => {
+ expect(getLastDayOfMonth(2024, 4)).toBe(30);
+ expect(getLastDayOfMonth(2024, 6)).toBe(30);
+ expect(getLastDayOfMonth(2024, 9)).toBe(30);
+ expect(getLastDayOfMonth(2024, 11)).toBe(30);
+ });
+ });
+
+ describe('isValidDateInMonth', () => {
+ it('isValidDateInMonth - 유효한 날짜를 올바르게 판별한다', () => {
+ expect(isValidDateInMonth(2024, 2, 29)).toBe(true);
+ expect(isValidDateInMonth(2024, 1, 31)).toBe(true);
+ expect(isValidDateInMonth(2024, 4, 30)).toBe(true);
+ });
+
+ it('isValidDateInMonth - 무효한 날짜를 올바르게 판별한다', () => {
+ expect(isValidDateInMonth(2023, 2, 29)).toBe(false);
+ expect(isValidDateInMonth(2024, 2, 30)).toBe(false);
+ expect(isValidDateInMonth(2024, 4, 31)).toBe(false);
+ expect(isValidDateInMonth(2024, 11, 31)).toBe(false);
+ });
+ });
+
+ describe('addDays', () => {
+ it('addDays - ISO 날짜 문자열에 일 단위 증분을 적용한다', () => {
+ expect(addDays('2024-01-01', 1)).toBe('2024-01-02');
+ expect(addDays('2024-01-01', 3)).toBe('2024-01-04');
+ });
+
+ it('addDays - 월 경계를 넘어가는 증분을 처리한다', () => {
+ expect(addDays('2024-01-31', 1)).toBe('2024-02-01');
+ expect(addDays('2024-02-28', 1)).toBe('2024-02-29');
+ expect(addDays('2024-02-29', 1)).toBe('2024-03-01');
+ });
+
+ it('addDays - 음수 증분으로 날짜를 감소시킨다', () => {
+ expect(addDays('2024-01-05', -2)).toBe('2024-01-03');
+ });
+ });
+
+ describe('addWeeks', () => {
+ it('addWeeks - ISO 날짜 문자열에 주 단위 증분을 적용한다', () => {
+ expect(addWeeks('2024-01-01', 1)).toBe('2024-01-08');
+ expect(addWeeks('2024-01-01', 2)).toBe('2024-01-15');
+ });
+
+ it('addWeeks - 월 경계를 넘어가는 증분을 처리한다', () => {
+ expect(addWeeks('2024-01-25', 1)).toBe('2024-02-01');
+ });
+ });
+
+ describe('addMonths', () => {
+ it('addMonths - ISO 날짜 문자열에 월 단위 증분을 적용한다', () => {
+ expect(addMonths('2024-01-15', 1)).toBe('2024-02-15');
+ expect(addMonths('2024-01-15', 2)).toBe('2024-03-15');
+ });
+
+ it('addMonths - 연도 경계를 넘어가는 증분을 처리한다', () => {
+ expect(addMonths('2024-11-15', 2)).toBe('2025-01-15');
+ });
+
+ it('addMonths - 대상 월에 날짜가 없으면 해당 월의 마지막 날로 조정한다', () => {
+ expect(addMonths('2024-01-31', 1)).toBe('2024-02-29');
+ expect(addMonths('2024-03-31', 1)).toBe('2024-04-30');
+ });
+ });
+
+ describe('addYears', () => {
+ it('addYears - ISO 날짜 문자열에 연 단위 증분을 적용한다', () => {
+ expect(addYears('2024-03-15', 1)).toBe('2025-03-15');
+ expect(addYears('2024-03-15', 2)).toBe('2026-03-15');
+ });
+
+ it('addYears - 윤년 2월 29일에서 평년으로 증분 시 2월 28일로 조정한다', () => {
+ expect(addYears('2024-02-29', 1)).toBe('2025-02-28');
+ });
+ });
+});
diff --git a/src/__tests__/unit/repeatGroupUtils.spec.ts b/src/__tests__/unit/repeatGroupUtils.spec.ts
new file mode 100644
index 00000000..3b2afc28
--- /dev/null
+++ b/src/__tests__/unit/repeatGroupUtils.spec.ts
@@ -0,0 +1,139 @@
+/**
+ * Unit Test: repeatGroupUtils
+ *
+ * 반복 일정 그룹 식별 로직을 테스트합니다.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import type { Event } from '../../types';
+// TODO: Stage 7에서 구현 예정
+import { findRepeatGroup } from '../../utils/repeatGroupUtils';
+
+describe('repeatGroupUtils', () => {
+ const createMockEvent = (overrides?: Partial): Event => ({
+ id: 'event-1',
+ title: '팀 미팅',
+ date: '2025-10-06',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실',
+ category: '업무',
+ repeat: { type: 'weekly', interval: 1 },
+ notificationTime: 10,
+ ...overrides,
+ });
+
+ describe('findRepeatGroup', () => {
+ it('TC-F-1: 같은 그룹의 모든 이벤트를 반환한다', () => {
+ // Arrange
+ const mockRepeatingGroup: Event[] = [
+ createMockEvent({ id: 'repeat-1', date: '2025-10-06' }),
+ createMockEvent({ id: 'repeat-2', date: '2025-10-13' }),
+ createMockEvent({ id: 'repeat-3', date: '2025-10-20' }),
+ ];
+ const targetEvent = mockRepeatingGroup[0];
+
+ // Act
+ const result = findRepeatGroup(mockRepeatingGroup, targetEvent);
+
+ // Assert
+ expect(result).toHaveLength(3);
+ expect(result).toEqual(expect.arrayContaining(mockRepeatingGroup));
+ });
+
+ it('TC-F-2: 유일한 반복 일정은 자기 자신만 반환한다', () => {
+ // Arrange
+ const singleEvent = createMockEvent({ id: 'single-1' });
+ const events = [singleEvent];
+
+ // Act
+ const result = findRepeatGroup(events, singleEvent);
+
+ // Assert
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(singleEvent);
+ });
+
+ it('TC-F-3: 제목이 같지만 시간이 다른 이벤트는 제외한다', () => {
+ // Arrange
+ const event1 = createMockEvent({ id: 'event-1', startTime: '10:00' });
+ const event2 = createMockEvent({ id: 'event-2', startTime: '11:00' });
+ const events = [event1, event2];
+
+ // Act
+ const result = findRepeatGroup(events, event1);
+
+ // Assert
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(event1);
+ });
+
+ it('TC-F-4: 제목과 시간이 같지만 반복 유형이 다른 이벤트는 제외한다', () => {
+ // Arrange
+ const event1 = createMockEvent({
+ id: 'event-1',
+ repeat: { type: 'weekly', interval: 1 },
+ });
+ const event2 = createMockEvent({
+ id: 'event-2',
+ repeat: { type: 'daily', interval: 1 },
+ });
+ const events = [event1, event2];
+
+ // Act
+ const result = findRepeatGroup(events, event1);
+
+ // Assert
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(event1);
+ });
+
+ it('TC-F-5: 일반 일정(repeat.type="none")은 자기 자신만 반환한다', () => {
+ // Arrange
+ const normalEvent1 = createMockEvent({
+ id: 'normal-1',
+ repeat: { type: 'none', interval: 1 },
+ });
+ const normalEvent2 = createMockEvent({
+ id: 'normal-2',
+ repeat: { type: 'none', interval: 1 },
+ });
+ const events = [normalEvent1, normalEvent2];
+
+ // Act
+ const result = findRepeatGroup(events, normalEvent1);
+
+ // Assert
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(normalEvent1);
+ });
+
+ it('TC-F-6: 빈 배열 입력 시 빈 배열을 반환한다', () => {
+ // Arrange
+ const events: Event[] = [];
+ const targetEvent = createMockEvent();
+
+ // Act
+ const result = findRepeatGroup(events, targetEvent);
+
+ // Assert
+ expect(result).toHaveLength(0);
+ });
+
+ it('TC-F-7: 존재하지 않는 이벤트는 빈 배열을 반환한다', () => {
+ // Arrange
+ const event1 = createMockEvent({ id: 'event-1' });
+ const event2 = createMockEvent({ id: 'event-2' });
+ const event3 = createMockEvent({ id: 'event-3', title: '다른 회의' });
+ const events = [event1, event2];
+
+ // Act
+ const result = findRepeatGroup(events, event3);
+
+ // Assert
+ expect(result).toHaveLength(0);
+ });
+ });
+});
diff --git a/src/__tests__/unit/repeatOptionsProvider.spec.ts b/src/__tests__/unit/repeatOptionsProvider.spec.ts
new file mode 100644
index 00000000..137a70d3
--- /dev/null
+++ b/src/__tests__/unit/repeatOptionsProvider.spec.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+
+import { getRepeatOptionLabel, getRepeatOptions } from '../../utils/repeatOptionsProvider';
+
+describe('RepeatOptionsProvider', () => {
+ describe('getRepeatOptions', () => {
+ it('getRepeatOptions - 반복 유형 옵션 배열을 반환한다', () => {
+ const result = getRepeatOptions();
+
+ expect(result).toEqual(['daily', 'weekly', 'monthly', 'yearly']);
+ });
+
+ it('getRepeatOptions - 옵션 순서가 일관되게 유지된다', () => {
+ const result1 = getRepeatOptions();
+ const result2 = getRepeatOptions();
+
+ expect(result1).toEqual(result2);
+ });
+ });
+
+ describe('getRepeatOptionLabel', () => {
+ it('getRepeatOptionLabel - 각 반복 유형에 대응하는 한글 레이블을 반환한다', () => {
+ expect(getRepeatOptionLabel('daily')).toBe('매일');
+ expect(getRepeatOptionLabel('weekly')).toBe('매주');
+ expect(getRepeatOptionLabel('monthly')).toBe('매월');
+ expect(getRepeatOptionLabel('yearly')).toBe('매년');
+ });
+
+ it('getRepeatOptionLabel - none 타입에 대한 레이블을 반환한다', () => {
+ expect(getRepeatOptionLabel('none')).toBe('반복 안함');
+ });
+ });
+});
diff --git a/src/__tests__/unit/repeatScheduler.endDate.spec.ts b/src/__tests__/unit/repeatScheduler.endDate.spec.ts
new file mode 100644
index 00000000..11bc029c
--- /dev/null
+++ b/src/__tests__/unit/repeatScheduler.endDate.spec.ts
@@ -0,0 +1,147 @@
+/**
+ * Unit Test: repeatScheduler - generateRecurringEventsUntilEndDate
+ *
+ * 종료 날짜까지 반복 일정을 생성하는 로직을 테스트합니다.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import type { EventForm } from '../../types';
+// TODO: Stage 7에서 구현 예정
+import { generateRecurringEventsUntilEndDate } from '../../utils/repeatScheduler';
+
+describe('repeatScheduler', () => {
+ describe('generateRecurringEventsUntilEndDate', () => {
+ const createBaseEvent = (overrides?: Partial): EventForm => ({
+ title: '테스트 이벤트',
+ date: '2025-10-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: 'Unit test',
+ location: 'Test',
+ category: '테스트',
+ repeat: {
+ type: 'daily',
+ interval: 1,
+ },
+ notificationTime: 10,
+ ...overrides,
+ });
+
+ it('TC-R-1: 매일 반복, 5일간', () => {
+ // Arrange
+ const baseEvent = createBaseEvent();
+ const endDate = '2025-10-05';
+
+ // Act
+ const events = generateRecurringEventsUntilEndDate(baseEvent, endDate);
+
+ // Assert
+ expect(events).toHaveLength(5);
+ expect(events[0].date).toBe('2025-10-01');
+ expect(events[4].date).toBe('2025-10-05');
+ });
+
+ it('TC-R-2: 매일 반복, 10일간', () => {
+ // Arrange
+ const baseEvent = createBaseEvent();
+ const endDate = '2025-10-10';
+
+ // Act
+ const events = generateRecurringEventsUntilEndDate(baseEvent, endDate);
+
+ // Assert
+ expect(events).toHaveLength(10);
+ expect(events[9].date).toBe('2025-10-10');
+
+ // 10/11은 생성되지 않아야 함
+ const hasEvent1011 = events.some((event) => event.date === '2025-10-11');
+ expect(hasEvent1011).toBe(false);
+ });
+
+ it('TC-R-3: 매주 반복, 1개월', () => {
+ // Arrange
+ const baseEvent = createBaseEvent({
+ repeat: { type: 'weekly', interval: 1 },
+ });
+ const endDate = '2025-10-31';
+
+ // Act
+ const events = generateRecurringEventsUntilEndDate(baseEvent, endDate);
+
+ // Assert
+ expect(events).toHaveLength(5);
+ expect(events[0].date).toBe('2025-10-01');
+ expect(events[1].date).toBe('2025-10-08');
+ expect(events[2].date).toBe('2025-10-15');
+ expect(events[3].date).toBe('2025-10-22');
+ expect(events[4].date).toBe('2025-10-29');
+ });
+
+ it('TC-R-4: 매월 반복, 3개월', () => {
+ // Arrange
+ const baseEvent = createBaseEvent({
+ repeat: { type: 'monthly', interval: 1 },
+ });
+ const endDate = '2025-12-31';
+
+ // Act
+ const events = generateRecurringEventsUntilEndDate(baseEvent, endDate);
+
+ // Assert
+ expect(events).toHaveLength(3);
+ expect(events[0].date).toBe('2025-10-01');
+ expect(events[1].date).toBe('2025-11-01');
+ expect(events[2].date).toBe('2025-12-01');
+ });
+
+ it('TC-R-5: 연간 반복, 같은 해', () => {
+ // Arrange
+ const baseEvent = createBaseEvent({
+ repeat: { type: 'yearly', interval: 1 },
+ });
+ const endDate = '2025-12-31';
+
+ // Act
+ const events = generateRecurringEventsUntilEndDate(baseEvent, endDate);
+
+ // Assert
+ expect(events).toHaveLength(1);
+ expect(events[0].date).toBe('2025-10-01');
+ });
+
+ it('TC-R-6: 종료 날짜 = 시작 날짜', () => {
+ // Arrange
+ const baseEvent = createBaseEvent({
+ date: '2025-10-15',
+ });
+ const endDate = '2025-10-15';
+
+ // Act
+ const events = generateRecurringEventsUntilEndDate(baseEvent, endDate);
+
+ // Assert
+ expect(events).toHaveLength(1);
+ expect(events[0].date).toBe('2025-10-15');
+ });
+
+ it('TC-R-7: 종료 날짜가 첫 반복 전 (경계값)', () => {
+ // Arrange
+ const baseEvent = createBaseEvent({
+ repeat: { type: 'weekly', interval: 1 },
+ });
+ const endDate = '2025-10-02';
+
+ // Act
+ const events = generateRecurringEventsUntilEndDate(baseEvent, endDate);
+
+ // Assert
+ expect(events).toHaveLength(1);
+ expect(events[0].date).toBe('2025-10-01');
+
+ // 다음 주(10/8)는 생성되지 않아야 함
+ const hasEvent1008 = events.some((event) => event.date === '2025-10-08');
+ expect(hasEvent1008).toBe(false);
+ });
+ });
+});
diff --git a/src/__tests__/unit/repeatScheduler.spec.ts b/src/__tests__/unit/repeatScheduler.spec.ts
new file mode 100644
index 00000000..8ed930f0
--- /dev/null
+++ b/src/__tests__/unit/repeatScheduler.spec.ts
@@ -0,0 +1,156 @@
+import { describe, it, expect } from 'vitest';
+
+import type { EventForm } from '../../types';
+import {
+ generateDailyOccurrences,
+ generateMonthlyOccurrences,
+ generateSingleEvent,
+ generateWeeklyOccurrences,
+ generateYearlyOccurrences,
+ type RecurringGenerationParams,
+} from '../../utils/repeatScheduler';
+
+const createBaseEvent = (overrides: Partial = {}): EventForm => ({
+ title: '테스트 일정',
+ date: '2024-01-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '',
+ location: '',
+ category: '업무',
+ repeat: {
+ type: 'none',
+ interval: 1,
+ ...overrides.repeat,
+ },
+ notificationTime: 10,
+ ...overrides,
+});
+
+const toParams = (baseEvent: EventForm, occurrenceCount: number): RecurringGenerationParams => ({
+ baseEvent,
+ occurrenceCount,
+});
+
+describe('RepeatScheduler', () => {
+ describe('generateDailyOccurrences', () => {
+ it('generateDailyOccurrences - 요청된 횟수만큼 연속된 날짜를 생성한다', () => {
+ const baseEvent = createBaseEvent({
+ date: '2024-01-01',
+ repeat: { type: 'daily', interval: 1 },
+ });
+
+ const result = generateDailyOccurrences(toParams(baseEvent, 3));
+
+ expect(result).toHaveLength(3);
+ expect(result.map((e) => e.date)).toEqual(['2024-01-01', '2024-01-02', '2024-01-03']);
+ });
+
+ it('generateDailyOccurrences - 모든 이벤트 속성을 유지한다', () => {
+ const baseEvent = createBaseEvent({
+ date: '2024-01-01',
+ title: '매일 회의',
+ repeat: { type: 'daily', interval: 1 },
+ });
+
+ const result = generateDailyOccurrences(toParams(baseEvent, 2));
+
+ expect(result[0].title).toBe('매일 회의');
+ expect(result[1].title).toBe('매일 회의');
+ });
+ });
+
+ describe('generateWeeklyOccurrences', () => {
+ it('generateWeeklyOccurrences - 동일한 요일로 주간 반복 일정을 생성한다', () => {
+ const baseEvent = createBaseEvent({
+ date: '2024-01-03',
+ repeat: { type: 'weekly', interval: 1 },
+ });
+
+ const result = generateWeeklyOccurrences(toParams(baseEvent, 4));
+
+ expect(result).toHaveLength(4);
+ expect(result.map((e) => e.date)).toEqual([
+ '2024-01-03',
+ '2024-01-10',
+ '2024-01-17',
+ '2024-01-24',
+ ]);
+ });
+ });
+
+ describe('generateMonthlyOccurrences', () => {
+ it('generateMonthlyOccurrences - 월 반복 시 대상 일이 없는 월을 건너뛴다', () => {
+ const baseEvent = createBaseEvent({
+ date: '2024-01-31',
+ repeat: { type: 'monthly', interval: 1 },
+ });
+
+ const result = generateMonthlyOccurrences(toParams(baseEvent, 6));
+ const dates = result.map((e) => e.date);
+
+ expect(dates).toContain('2024-01-31');
+ expect(dates).not.toContain('2024-02-31');
+ expect(dates).toContain('2024-03-31');
+ expect(dates).not.toContain('2024-04-31');
+ expect(dates).toContain('2024-05-31');
+ expect(dates).not.toContain('2024-06-31');
+ });
+
+ it('generateMonthlyOccurrences - 31일이 있는 월만 순차적으로 포함한다', () => {
+ const baseEvent = createBaseEvent({
+ date: '2024-01-31',
+ repeat: { type: 'monthly', interval: 1 },
+ });
+
+ const result = generateMonthlyOccurrences(toParams(baseEvent, 4));
+
+ expect(result.map((e) => e.date)).toEqual([
+ '2024-01-31',
+ '2024-03-31',
+ '2024-05-31',
+ '2024-07-31',
+ ]);
+ });
+ });
+
+ describe('generateYearlyOccurrences', () => {
+ it('generateYearlyOccurrences - 윤년 2월 29일 예외를 처리하며 연간 반복을 생성한다', () => {
+ const baseEvent = createBaseEvent({
+ date: '2024-02-29',
+ repeat: { type: 'yearly', interval: 1 },
+ });
+
+ const result = generateYearlyOccurrences(toParams(baseEvent, 3));
+
+ expect(result.map((e) => e.date)).toEqual(['2024-02-29', '2028-02-29', '2032-02-29']);
+ });
+
+ it('generateYearlyOccurrences - 평년에는 2월 29일을 건너뛴다', () => {
+ const baseEvent = createBaseEvent({
+ date: '2024-02-29',
+ repeat: { type: 'yearly', interval: 1 },
+ });
+
+ const result = generateYearlyOccurrences(toParams(baseEvent, 5));
+ const dates = result.map((e) => e.date);
+
+ expect(dates).not.toContain('2025-02-29');
+ expect(dates).not.toContain('2026-02-29');
+ expect(dates).not.toContain('2027-02-29');
+ });
+ });
+
+ describe('generateSingleEvent', () => {
+ it('generateSingleEvent - 반복 미선택 시 단일 일정만 반환한다', () => {
+ const baseEvent = createBaseEvent({
+ date: '2024-01-15',
+ });
+
+ const result = generateSingleEvent(baseEvent);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(baseEvent);
+ });
+ });
+});
diff --git a/src/__tests__/unit/repeatValidation.spec.ts b/src/__tests__/unit/repeatValidation.spec.ts
new file mode 100644
index 00000000..56d5eb37
--- /dev/null
+++ b/src/__tests__/unit/repeatValidation.spec.ts
@@ -0,0 +1,92 @@
+/**
+ * Unit Test: repeatValidation
+ *
+ * 반복 일정의 종료 날짜 검증 로직을 테스트합니다.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+// TODO: Stage 7에서 구현 예정
+import { validateRepeatEndDate } from '../../utils/repeatValidation';
+
+describe('repeatValidation', () => {
+ describe('validateRepeatEndDate', () => {
+ it('TC-V-1: 종료 날짜가 시작 날짜보다 이전이면 invalid', () => {
+ // Arrange
+ const startDate = '2025-10-15';
+ const endDate = '2025-10-10';
+
+ // Act
+ const result = validateRepeatEndDate(startDate, endDate);
+
+ // Assert
+ expect(result.valid).toBe(false);
+ expect(result.error).toBe('종료 날짜는 시작 날짜 이후여야 합니다');
+ });
+
+ it('TC-V-2: 종료 날짜가 시작 날짜와 같으면 valid', () => {
+ // Arrange
+ const startDate = '2025-10-15';
+ const endDate = '2025-10-15';
+
+ // Act
+ const result = validateRepeatEndDate(startDate, endDate);
+
+ // Assert
+ expect(result.valid).toBe(true);
+ expect(result.error).toBeUndefined();
+ });
+
+ it('TC-V-3: 종료 날짜가 시작 날짜보다 이후면 valid', () => {
+ // Arrange
+ const startDate = '2025-10-15';
+ const endDate = '2025-10-20';
+
+ // Act
+ const result = validateRepeatEndDate(startDate, endDate);
+
+ // Assert
+ expect(result.valid).toBe(true);
+ expect(result.error).toBeUndefined();
+ });
+
+ it('TC-V-4: 종료 날짜가 undefined면 valid (기본값 적용)', () => {
+ // Arrange
+ const startDate = '2025-10-15';
+ const endDate = undefined;
+
+ // Act
+ const result = validateRepeatEndDate(startDate, endDate);
+
+ // Assert
+ expect(result.valid).toBe(true);
+ expect(result.error).toBeUndefined();
+ });
+
+ it('TC-V-5: 종료 날짜가 빈 문자열이면 valid (기본값 적용)', () => {
+ // Arrange
+ const startDate = '2025-10-15';
+ const endDate = '';
+
+ // Act
+ const result = validateRepeatEndDate(startDate, endDate);
+
+ // Assert
+ expect(result.valid).toBe(true);
+ expect(result.error).toBeUndefined();
+ });
+
+ it('TC-V-6: 잘못된 날짜 형식이면 invalid', () => {
+ // Arrange
+ const startDate = '2025-10-15';
+ const endDate = 'invalid-date';
+
+ // Act
+ const result = validateRepeatEndDate(startDate, endDate);
+
+ // Assert
+ expect(result.valid).toBe(false);
+ expect(result.error).toBe('올바른 날짜 형식이 아닙니다');
+ });
+ });
+});
diff --git a/src/hooks/useEventForm.ts b/src/hooks/useEventForm.ts
index 9dfcc46a..c60e043c 100644
--- a/src/hooks/useEventForm.ts
+++ b/src/hooks/useEventForm.ts
@@ -13,7 +13,9 @@ export const useEventForm = (initialEvent?: Event) => {
const [description, setDescription] = useState(initialEvent?.description || '');
const [location, setLocation] = useState(initialEvent?.location || '');
const [category, setCategory] = useState(initialEvent?.category || '업무');
- const [isRepeating, setIsRepeating] = useState(initialEvent?.repeat.type !== 'none');
+ const [isRepeating, setIsRepeating] = useState(
+ initialEvent ? initialEvent.repeat.type !== 'none' : false
+ );
const [repeatType, setRepeatType] = useState(initialEvent?.repeat.type || 'none');
const [repeatInterval, setRepeatInterval] = useState(initialEvent?.repeat.interval || 1);
const [repeatEndDate, setRepeatEndDate] = useState(initialEvent?.repeat.endDate || '');
diff --git a/src/hooks/useEventOperations.ts b/src/hooks/useEventOperations.ts
index 3216cc05..ed059e34 100644
--- a/src/hooks/useEventOperations.ts
+++ b/src/hooks/useEventOperations.ts
@@ -2,49 +2,162 @@ import { useSnackbar } from 'notistack';
import { useEffect, useState } from 'react';
import { Event, EventForm } from '../types';
+import { applyEventUpdate } from '../utils/eventUpdateUtils';
+import { getRepeatEndDate } from '../utils/repeatDateUtils';
+import { findRepeatGroup } from '../utils/repeatGroupUtils';
+import { generateRecurringEventsUntilEndDate } from '../utils/repeatScheduler';
+import { validateRepeatEndDate } from '../utils/repeatValidation';
export const useEventOperations = (editing: boolean, onSave?: () => void) => {
const [events, setEvents] = useState([]);
const { enqueueSnackbar } = useSnackbar();
+ // 공통: 이벤트 목록 가져오기
const fetchEvents = async () => {
try {
const response = await fetch('/api/events');
- if (!response.ok) {
- throw new Error('Failed to fetch events');
- }
- const { events } = await response.json();
- setEvents(events);
+ if (!response.ok) throw new Error('Failed to fetch events');
+ const data = await response.json();
+ // 서버는 항상 { events: [...] } 형태로 응답
+ const list: Event[] = Array.isArray(data?.events)
+ ? data.events
+ : Array.isArray(data)
+ ? data
+ : [];
+ if (!Array.isArray(list)) throw new Error('Invalid events payload');
+ setEvents(list);
} catch (error) {
console.error('Error fetching events:', error);
enqueueSnackbar('이벤트 로딩 실패', { variant: 'error' });
}
};
- const saveEvent = async (eventData: Event | EventForm) => {
+ // 저장 (생성/수정 모두)
+ const saveEvent = async (
+ eventData: Event | EventForm,
+ editMode: 'single' | 'all' | null = null,
+ allEvents: Event[] = []
+ ) => {
try {
- let response;
+ const isRecurring = eventData.repeat?.type && eventData.repeat.type !== 'none';
+
+ // 1️⃣ 반복 일정의 종료 날짜 검증
+ if (isRecurring) {
+ const validation = validateRepeatEndDate(eventData.date, eventData.repeat.endDate);
+ if (!validation.valid) {
+ enqueueSnackbar(validation.error || '종료 날짜 검증 실패', { variant: 'error' });
+ return;
+ }
+ }
+
+ let response: Response | undefined;
+
+ // 2️⃣ 수정 모드
if (editing) {
- response = await fetch(`/api/events/${(eventData as Event).id}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(eventData),
- });
+ const currentEvent = eventData as Event;
+
+ if (editMode === 'single' || editMode === 'all') {
+ const originalEvent = allEvents.find((e) => e.id === currentEvent.id);
+ if (!originalEvent) throw new Error('Original event not found');
+
+ const repeatGroup = findRepeatGroup(allEvents, originalEvent);
+
+ if (repeatGroup.length === 0) {
+ throw new Error('Repeat group not found');
+ }
+
+ if (editMode === 'single') {
+ // 단일 수정 → repeat.type = 'none'
+ const updatedEvent = applyEventUpdate(
+ originalEvent,
+ {
+ ...eventData,
+ repeat: { type: 'none', interval: 1 },
+ },
+ 'single'
+ );
+ response = await fetch(`/api/events/${currentEvent.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updatedEvent),
+ });
+ } else if (editMode === 'all') {
+ // 전체 수정 → 그룹 전체 동일 필드 업데이트
+ // 각 이벤트의 date는 유지하고, 나머지 필드만 업데이트
+ const updatedEvents = repeatGroup.map((groupEvent) => {
+ // 전체 수정: repeat.type과 interval은 유지하되, endDate만 업데이트 가능
+ const updatedRepeat = eventData.repeat
+ ? {
+ ...groupEvent.repeat,
+ endDate:
+ eventData.repeat.endDate !== undefined
+ ? eventData.repeat.endDate
+ : groupEvent.repeat.endDate,
+ // repeat.id는 항상 원본 유지
+ id: groupEvent.repeat.id,
+ }
+ : groupEvent.repeat;
+
+ const updated = applyEventUpdate(
+ groupEvent,
+ {
+ title: eventData.title,
+ startTime: eventData.startTime,
+ endTime: eventData.endTime,
+ description: eventData.description,
+ location: eventData.location,
+ category: eventData.category,
+ notificationTime: eventData.notificationTime,
+ repeat: updatedRepeat,
+ // date는 각 이벤트의 원래 날짜 유지
+ },
+ 'all'
+ );
+ // date 필드를 명시적으로 보존
+ updated.date = groupEvent.date;
+ return updated;
+ });
+
+ // 배치 업데이트 API 사용 (더 안전함)
+ response = await fetch('/api/events-list', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ events: updatedEvents }),
+ });
+ }
+ } else {
+ // 일반 일정 수정
+ response = await fetch(`/api/events/${(eventData as Event).id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(eventData),
+ });
+ }
} else {
- response = await fetch('/api/events', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(eventData),
- });
+ // 3️⃣ 신규 생성 로직
+ if (isRecurring) {
+ const endDate = getRepeatEndDate(eventData.repeat.endDate);
+ const recurringEvents = generateRecurringEventsUntilEndDate(eventData, endDate);
+ response = await fetch('/api/events-list', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ events: recurringEvents }),
+ });
+ } else {
+ response = await fetch('/api/events', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(eventData),
+ });
+ }
}
- if (!response.ok) {
- throw new Error('Failed to save event');
- }
+ if (!response || !response.ok) throw new Error('Failed to save event');
await fetchEvents();
onSave?.();
- enqueueSnackbar(editing ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.', {
+
+ enqueueSnackbar(editing ? '일정이 수정되었습니다' : '일정이 추가되었습니다', {
variant: 'success',
});
} catch (error) {
@@ -53,14 +166,11 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => {
}
};
+ // 삭제
const deleteEvent = async (id: string) => {
try {
const response = await fetch(`/api/events/${id}`, { method: 'DELETE' });
-
- if (!response.ok) {
- throw new Error('Failed to delete event');
- }
-
+ if (!response.ok) throw new Error('Failed to delete event');
await fetchEvents();
enqueueSnackbar('일정이 삭제되었습니다.', { variant: 'info' });
} catch (error) {
@@ -69,10 +179,11 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => {
}
};
- async function init() {
+ // 초기 로드
+ const init = async () => {
await fetchEvents();
enqueueSnackbar('일정 로딩 완료!', { variant: 'info' });
- }
+ };
useEffect(() => {
init();
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index f9ec573b..aa3ebf3d 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { Event } from '../types';
import { createNotificationMessage, getUpcomingEvents } from '../utils/notificationUtils';
@@ -7,7 +7,7 @@ export const useNotifications = (events: Event[]) => {
const [notifications, setNotifications] = useState<{ id: string; message: string }[]>([]);
const [notifiedEvents, setNotifiedEvents] = useState([]);
- const checkUpcomingEvents = () => {
+ const checkUpcomingEvents = useCallback(() => {
const now = new Date();
const upcomingEvents = getUpcomingEvents(events, now, notifiedEvents);
@@ -20,7 +20,7 @@ export const useNotifications = (events: Event[]) => {
]);
setNotifiedEvents((prev) => [...prev, ...upcomingEvents.map(({ id }) => id)]);
- };
+ }, [events, notifiedEvents]);
const removeNotification = (index: number) => {
setNotifications((prev) => prev.filter((_, i) => i !== index));
@@ -29,7 +29,7 @@ export const useNotifications = (events: Event[]) => {
useEffect(() => {
const interval = setInterval(checkUpcomingEvents, 1000); // 1초마다 체크
return () => clearInterval(interval);
- }, [events, notifiedEvents]);
+ }, [checkUpcomingEvents]);
return { notifications, notifiedEvents, setNotifications, removeNotification };
};
diff --git a/src/types.ts b/src/types.ts
index a08a8aa7..c4f0b84c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -4,6 +4,7 @@ export interface RepeatInfo {
type: RepeatType;
interval: number;
endDate?: string;
+ id?: string; // 반복 그룹 식별자 (서버에서 생성)
}
export interface EventForm {
diff --git a/src/utils/eventTypeChecker.ts b/src/utils/eventTypeChecker.ts
new file mode 100644
index 00000000..1d3c119c
--- /dev/null
+++ b/src/utils/eventTypeChecker.ts
@@ -0,0 +1,19 @@
+import type { Event, EventForm } from '../types';
+
+/**
+ * 이벤트가 반복 일정인지 확인
+ *
+ * @param event - 검증할 이벤트 객체 (null/undefined 안전)
+ * @returns 반복 일정이면 true, 일반 일정(none) 또는 유효하지 않으면 false
+ *
+ * @example
+ * isRepeatingEvent({ repeat: { type: 'daily' } }) // true
+ * isRepeatingEvent({ repeat: { type: 'none' } }) // false
+ * isRepeatingEvent({}) // false
+ * isRepeatingEvent(null) // false
+ */
+export function isRepeatingEvent(event: Event | EventForm | null | undefined): boolean {
+ // [Refactored] 옵셔널 체이닝 중복 제거 및 가독성 개선
+ const repeatType = event?.repeat?.type;
+ return repeatType !== undefined && repeatType !== 'none';
+}
diff --git a/src/utils/eventUpdateUtils.ts b/src/utils/eventUpdateUtils.ts
new file mode 100644
index 00000000..023807c7
--- /dev/null
+++ b/src/utils/eventUpdateUtils.ts
@@ -0,0 +1,63 @@
+/**
+ * 이벤트 수정 적용 유틸리티
+ */
+
+import type { Event } from '../types';
+
+/**
+ * 이벤트에 수정 사항을 적용합니다.
+ *
+ * - mode가 'single'인 경우: 단일 수정 → repeat.type을 'none'으로 변경
+ * - mode가 'all'인 경우: 전체 수정 → repeat.type 유지
+ * - 원본 이벤트의 repeat.type이 'none'인 경우: mode 무관하게 'none' 유지
+ * - updates가 빈 객체인 경우: 원본 이벤트 그대로 반환 (repeat.type 포함)
+ *
+ * @param event - 원본 이벤트
+ * @param updates - 수정할 필드들
+ * @param mode - 수정 모드 ('single' | 'all')
+ * @returns 수정이 적용된 이벤트
+ */
+export function applyEventUpdate(
+ event: Event,
+ updates: Partial,
+ mode: 'single' | 'all'
+): Event {
+ // 기본적으로 원본 이벤트를 복사하고 updates를 병합
+ const updatedEvent: Event = {
+ ...event,
+ ...updates,
+ };
+
+ // updates가 빈 객체면 원본 그대로 반환
+ if (Object.keys(updates).length === 0) {
+ return event;
+ }
+
+ // 원본 이벤트가 일반 일정이면 mode 무관하게 'none' 유지
+ if (event.repeat.type === 'none') {
+ updatedEvent.repeat = { ...event.repeat, type: 'none' };
+ return updatedEvent;
+ }
+
+ // mode에 따라 repeat.type 처리
+ if (mode === 'single') {
+ // 단일 수정: repeat.type을 'none'으로 변경
+ updatedEvent.repeat = { ...event.repeat, type: 'none' };
+ } else if (mode === 'all') {
+ // 전체 수정: repeat 필드를 업데이트에 따라 병합
+ if (updates.repeat) {
+ const nextRepeat = {
+ ...event.repeat,
+ ...updates.repeat,
+ // id는 항상 원본 유지
+ id: event.repeat.id || updates.repeat.id,
+ };
+ updatedEvent.repeat = nextRepeat;
+ } else {
+ // repeat가 업데이트에 없으면 원본 유지
+ updatedEvent.repeat = { ...event.repeat };
+ }
+ }
+
+ return updatedEvent;
+}
diff --git a/src/utils/repeatDateUtils.ts b/src/utils/repeatDateUtils.ts
new file mode 100644
index 00000000..9474ac3e
--- /dev/null
+++ b/src/utils/repeatDateUtils.ts
@@ -0,0 +1,84 @@
+export const isLeapYear = (year: number): boolean => {
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+};
+
+export const getLastDayOfMonth = (year: number, month: number): number => {
+ return new Date(year, month, 0).getDate();
+};
+
+export const isValidDateInMonth = (year: number, month: number, day: number): boolean => {
+ const lastDay = getLastDayOfMonth(year, month);
+ return day >= 1 && day <= lastDay;
+};
+
+export const addDays = (date: string, delta: number): string => {
+ const d = new Date(date);
+ d.setDate(d.getDate() + delta);
+ return d.toISOString().split('T')[0];
+};
+
+export const addWeeks = (date: string, delta: number): string => {
+ return addDays(date, delta * 7);
+};
+
+export const addMonths = (date: string, delta: number): string => {
+ const d = new Date(date);
+ const originalDay = d.getDate();
+
+ d.setMonth(d.getMonth() + delta);
+
+ // If day changed (e.g., Jan 31 + 1 month = Feb 28/29), adjust to last day of target month
+ if (d.getDate() !== originalDay) {
+ d.setDate(0); // Go back to last day of previous month
+ }
+
+ return d.toISOString().split('T')[0];
+};
+
+export const addYears = (date: string, delta: number): string => {
+ const d = new Date(date);
+ const originalDay = d.getDate();
+ const originalMonth = d.getMonth();
+
+ d.setFullYear(d.getFullYear() + delta);
+
+ // Handle Feb 29 -> Feb 28 for non-leap years
+ if (d.getMonth() !== originalMonth || d.getDate() !== originalDay) {
+ d.setDate(0); // Go back to last day of previous month
+ }
+
+ return d.toISOString().split('T')[0];
+};
+
+/**
+ * 반복 일정의 최대 종료 날짜
+ * PRD에서 정의: 2025-12-31까지만 생성 가능
+ */
+export const MAX_REPEAT_END_DATE = '2025-12-31';
+
+/**
+ * 반복 일정의 종료 날짜를 반환합니다.
+ * - undefined/null/빈 문자열인 경우 기본값(2025-12-31) 반환
+ * - 최대값(2025-12-31)을 초과하면 최대값으로 제한
+ * - 그 외에는 입력값 그대로 반환
+ *
+ * @param endDate - 종료 날짜 (YYYY-MM-DD 형식 또는 undefined)
+ * @returns 처리된 종료 날짜
+ */
+export function getRepeatEndDate(endDate: string | undefined): string {
+ // undefined, null, 빈 문자열인 경우 기본값 반환
+ if (!endDate || endDate === '') {
+ return MAX_REPEAT_END_DATE;
+ }
+
+ // 최대값 초과 여부 확인
+ const endDateObj = new Date(endDate);
+ const maxDateObj = new Date(MAX_REPEAT_END_DATE);
+
+ if (endDateObj.getTime() > maxDateObj.getTime()) {
+ return MAX_REPEAT_END_DATE;
+ }
+
+ // 유효한 날짜면 그대로 반환
+ return endDate;
+}
diff --git a/src/utils/repeatGroupUtils.ts b/src/utils/repeatGroupUtils.ts
new file mode 100644
index 00000000..9772aa6f
--- /dev/null
+++ b/src/utils/repeatGroupUtils.ts
@@ -0,0 +1,60 @@
+/**
+ * 반복 일정 그룹 식별 유틸리티
+ */
+
+import type { Event } from '../types';
+
+/**
+ * 주어진 이벤트와 같은 반복 그룹에 속하는 모든 이벤트를 찾습니다.
+ *
+ * 같은 그룹의 조건 (우선순위):
+ * 1. repeat.id가 있는 경우: repeat.id가 동일한 모든 이벤트
+ * 2. repeat.id가 없는 경우: 제목, 시작 시간, 종료 시간, 반복 유형, 반복 간격이 모두 동일한 이벤트
+ * - 반복 유형이 'none'이 아닌 경우만 (일반 일정은 그룹에 속하지 않음)
+ *
+ * @param events - 검색 대상 이벤트 배열
+ * @param targetEvent - 그룹을 찾을 기준 이벤트
+ * @returns 같은 그룹에 속하는 이벤트 배열
+ */
+export function findRepeatGroup(events: Event[], targetEvent: Event): Event[] {
+ // 빈 배열이면 빈 배열 반환
+ if (events.length === 0) {
+ return [];
+ }
+
+ // 일반 일정 (repeat.type = 'none')은 그룹에 속하지 않음
+ // 자기 자신만 반환
+ if (targetEvent.repeat.type === 'none') {
+ const found = events.find((e) => e.id === targetEvent.id);
+ return found ? [found] : [];
+ }
+
+ // targetEvent가 events 배열에 없으면 빈 배열 반환
+ const exists = events.some((e) => e.id === targetEvent.id);
+ if (!exists) {
+ return [];
+ }
+
+ // repeat.id가 있으면 repeat.id로 그룹 찾기 (가장 정확한 방법)
+ if (targetEvent.repeat.id) {
+ const groupById = events.filter(
+ (event) => event.repeat.id === targetEvent.repeat.id && event.repeat.type !== 'none'
+ );
+ // repeat.id로 그룹을 찾았으면 반환
+ if (groupById.length > 0) {
+ return groupById;
+ }
+ // repeat.id로 찾지 못했으면 fallback 로직 사용 (repeat.id가 없는 이벤트도 포함)
+ }
+
+ // repeat.id가 없으면 기존 로직 사용 (제목, 시간, 반복 유형/간격으로 그룹 찾기)
+ return events.filter(
+ (event) =>
+ event.title === targetEvent.title &&
+ event.startTime === targetEvent.startTime &&
+ event.endTime === targetEvent.endTime &&
+ event.repeat.type === targetEvent.repeat.type &&
+ event.repeat.interval === targetEvent.repeat.interval &&
+ event.repeat.type !== 'none' // 일반 일정 제외
+ );
+}
diff --git a/src/utils/repeatOptionsProvider.ts b/src/utils/repeatOptionsProvider.ts
new file mode 100644
index 00000000..188c9343
--- /dev/null
+++ b/src/utils/repeatOptionsProvider.ts
@@ -0,0 +1,17 @@
+import type { RepeatType } from '../types';
+
+export const getRepeatOptions = (): Exclude[] => {
+ return ['daily', 'weekly', 'monthly', 'yearly'];
+};
+
+export const getRepeatOptionLabel = (type: RepeatType): string => {
+ const labels: Record = {
+ none: '반복 안함',
+ daily: '매일',
+ weekly: '매주',
+ monthly: '매월',
+ yearly: '매년',
+ };
+
+ return labels[type];
+};
diff --git a/src/utils/repeatScheduler.ts b/src/utils/repeatScheduler.ts
new file mode 100644
index 00000000..44473215
--- /dev/null
+++ b/src/utils/repeatScheduler.ts
@@ -0,0 +1,200 @@
+import type { EventForm, Event } from '../types';
+import { addDays, addWeeks, isValidDateInMonth } from './repeatDateUtils';
+
+export interface RecurringGenerationParams {
+ baseEvent: EventForm;
+ occurrenceCount: number;
+}
+
+export const generateDailyOccurrences = (params: RecurringGenerationParams): EventForm[] => {
+ const { baseEvent, occurrenceCount } = params;
+ const result: EventForm[] = [];
+
+ for (let i = 0; i < occurrenceCount; i++) {
+ result.push({
+ ...baseEvent,
+ date: addDays(baseEvent.date, i),
+ });
+ }
+
+ return result;
+};
+
+export const generateWeeklyOccurrences = (params: RecurringGenerationParams): EventForm[] => {
+ const { baseEvent, occurrenceCount } = params;
+ const result: EventForm[] = [];
+
+ for (let i = 0; i < occurrenceCount; i++) {
+ result.push({
+ ...baseEvent,
+ date: addWeeks(baseEvent.date, i),
+ });
+ }
+
+ return result;
+};
+
+export const generateMonthlyOccurrences = (params: RecurringGenerationParams): EventForm[] => {
+ const { baseEvent, occurrenceCount } = params;
+ const result: EventForm[] = [];
+
+ const [year, month, day] = baseEvent.date.split('-').map(Number);
+ let currentYear = year;
+ let currentMonth = month;
+
+ while (result.length < occurrenceCount) {
+ if (isValidDateInMonth(currentYear, currentMonth, day)) {
+ const dateStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(
+ day
+ ).padStart(2, '0')}`;
+ result.push({
+ ...baseEvent,
+ date: dateStr,
+ });
+ }
+
+ currentMonth++;
+ if (currentMonth > 12) {
+ currentMonth = 1;
+ currentYear++;
+ }
+ }
+
+ return result;
+};
+
+export const generateYearlyOccurrences = (params: RecurringGenerationParams): EventForm[] => {
+ const { baseEvent, occurrenceCount } = params;
+ const result: EventForm[] = [];
+
+ const [year, month, day] = baseEvent.date.split('-').map(Number);
+ let currentYear = year;
+
+ while (result.length < occurrenceCount) {
+ if (isValidDateInMonth(currentYear, month, day)) {
+ const dateStr = `${currentYear}-${String(month).padStart(2, '0')}-${String(day).padStart(
+ 2,
+ '0'
+ )}`;
+ result.push({
+ ...baseEvent,
+ date: dateStr,
+ });
+ }
+
+ currentYear++;
+ }
+
+ return result;
+};
+
+export const generateSingleEvent = (baseEvent: EventForm): EventForm[] => {
+ return [baseEvent];
+};
+
+/**
+ * Generate recurring events based on repeat type
+ */
+export const generateRecurringEvents = (baseEvent: EventForm | Event): EventForm[] => {
+ const repeatType = baseEvent.repeat.type;
+
+ if (repeatType === 'none') {
+ return generateSingleEvent(baseEvent as EventForm);
+ }
+
+ const params: RecurringGenerationParams = {
+ baseEvent: baseEvent as EventForm,
+ occurrenceCount: getDefaultCount(repeatType),
+ };
+
+ switch (repeatType) {
+ case 'daily':
+ return generateDailyOccurrences(params);
+ case 'weekly':
+ return generateWeeklyOccurrences(params);
+ case 'monthly':
+ return generateMonthlyOccurrences(params);
+ case 'yearly':
+ return generateYearlyOccurrences(params);
+ default:
+ return generateSingleEvent(baseEvent as EventForm);
+ }
+};
+
+/**
+ * Get default count for each repeat type
+ */
+function getDefaultCount(repeatType: string): number {
+ switch (repeatType) {
+ case 'daily':
+ return 7; // 7 days
+ case 'weekly':
+ return 4; // 4 weeks
+ case 'monthly':
+ return 12; // 12 months (1 year)
+ case 'yearly':
+ return 5; // 5 years
+ default:
+ return 1;
+ }
+}
+
+/**
+ * 종료 날짜까지 반복 일정을 생성합니다.
+ *
+ * @param baseEvent - 기본 이벤트 정보
+ * @param endDate - 종료 날짜 (YYYY-MM-DD 형식)
+ * @returns 생성된 이벤트 배열
+ */
+export function generateRecurringEventsUntilEndDate(
+ baseEvent: Event | EventForm,
+ endDate: string
+): Event[] {
+ const repeatType = baseEvent.repeat.type;
+
+ // 반복 안함인 경우 시작 이벤트 1개만 반환
+ if (repeatType === 'none') {
+ return generateSingleEvent(baseEvent as EventForm) as Event[];
+ }
+
+ const endDateObj = new Date(endDate);
+ const result: Event[] = [];
+
+ // 충분히 많은 수의 이벤트를 생성한 후 endDate까지 필터링
+ // 최대 365일치 생성 (daily 기준)
+ const params: RecurringGenerationParams = {
+ baseEvent: baseEvent as EventForm,
+ occurrenceCount: 365, // 충분히 큰 수
+ };
+
+ let candidates: EventForm[] = [];
+
+ switch (repeatType) {
+ case 'daily':
+ candidates = generateDailyOccurrences(params);
+ break;
+ case 'weekly':
+ candidates = generateWeeklyOccurrences({ ...params, occurrenceCount: 52 });
+ break;
+ case 'monthly':
+ candidates = generateMonthlyOccurrences({ ...params, occurrenceCount: 12 });
+ break;
+ case 'yearly':
+ candidates = generateYearlyOccurrences({ ...params, occurrenceCount: 5 });
+ break;
+ default:
+ return generateSingleEvent(baseEvent as EventForm) as Event[];
+ }
+
+ // endDate 이하인 이벤트만 필터링
+ for (const candidate of candidates) {
+ const candidateDateObj = new Date(candidate.date);
+ if (candidateDateObj.getTime() <= endDateObj.getTime()) {
+ result.push(candidate as Event);
+ } else {
+ break; // 정렬되어 있으므로 이후는 모두 제외
+ }
+ }
+
+ return result;
+}
diff --git a/src/utils/repeatValidation.ts b/src/utils/repeatValidation.ts
new file mode 100644
index 00000000..c1a02712
--- /dev/null
+++ b/src/utils/repeatValidation.ts
@@ -0,0 +1,46 @@
+/**
+ * 반복 일정의 종료 날짜 검증 유틸리티
+ */
+
+export interface ValidationResult {
+ valid: boolean;
+ error?: string;
+}
+
+/**
+ * 반복 일정의 종료 날짜가 유효한지 검증합니다.
+ *
+ * @param startDate - 시작 날짜 (YYYY-MM-DD 형식)
+ * @param endDate - 종료 날짜 (YYYY-MM-DD 형식 또는 undefined)
+ * @returns 검증 결과
+ */
+export function validateRepeatEndDate(
+ startDate: string,
+ endDate: string | undefined
+): ValidationResult {
+ // undefined나 빈 문자열인 경우 valid (기본값 적용)
+ if (!endDate || endDate === '') {
+ return { valid: true };
+ }
+
+ // 날짜 형식 검증
+ const endDateObj = new Date(endDate);
+ const startDateObj = new Date(startDate);
+
+ if (isNaN(endDateObj.getTime())) {
+ return {
+ valid: false,
+ error: '올바른 날짜 형식이 아닙니다',
+ };
+ }
+
+ // 종료 날짜가 시작 날짜보다 이전인지 검증
+ if (endDateObj.getTime() < startDateObj.getTime()) {
+ return {
+ valid: false,
+ error: '종료 날짜는 시작 날짜 이후여야 합니다',
+ };
+ }
+
+ return { valid: true };
+}