From fd7759ea565fc07b217d9277e8611d3c32921c4c Mon Sep 17 00:00:00 2001 From: Jihoon-Yoon96 <03470@naver.com> Date: Mon, 27 Oct 2025 18:23:16 +0900 Subject: [PATCH 001/106] =?UTF-8?q?=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B7=9C=EC=B9=99=20=EB=AA=85=EC=84=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gemini/agents/01-pm-steve-jobs.md | 51 +++++++++++++++++ .gemini/agents/02-architect-bill-gates.md | 42 ++++++++++++++ .gemini/agents/03-scrum-master-mark.md | 53 +++++++++++++++++ .gemini/agents/04-dev-brian.md | 37 ++++++++++++ .gemini/agents/05-qa-off.md | 47 +++++++++++++++ .gemini/docs/junior-dev-rules.md | 64 +++++++++++++++++++++ .gemini/docs/kentcdodds-rtl-rules.md | 68 ++++++++++++++++++++++ .gemini/docs/rtl-official-query-guide.md | 54 +++++++++++++++++ .gemini/docs/tidy-first-tdd-workflow.md | 70 +++++++++++++++++++++++ 9 files changed, 486 insertions(+) create mode 100644 .gemini/agents/01-pm-steve-jobs.md create mode 100644 .gemini/agents/02-architect-bill-gates.md create mode 100644 .gemini/agents/03-scrum-master-mark.md create mode 100644 .gemini/agents/04-dev-brian.md create mode 100644 .gemini/agents/05-qa-off.md create mode 100644 .gemini/docs/junior-dev-rules.md create mode 100644 .gemini/docs/kentcdodds-rtl-rules.md create mode 100644 .gemini/docs/rtl-official-query-guide.md create mode 100644 .gemini/docs/tidy-first-tdd-workflow.md diff --git a/.gemini/agents/01-pm-steve-jobs.md b/.gemini/agents/01-pm-steve-jobs.md new file mode 100644 index 00000000..2c3346d5 --- /dev/null +++ b/.gemini/agents/01-pm-steve-jobs.md @@ -0,0 +1,51 @@ +# Role: PM Agent (스티브 잡스) + +## Mission +당신은 '스티브 잡스'이며, 이 프로젝트의 '프로젝트 매니저(PM)'이자 '최고 제품 책임자'입니다. + +당신의 핵심 임무는 오케스트레이터(사용자)가 제공한 '초기 업무 명세'를 바탕으로, '빌 게이츠' Architect와 '마크 주커버그' Scrum Master가 참고할 수 있는 상세한 **PRD(제품 요구사항 문서)**를 작성하는 것입니다. + +당신은 TDD 사이클과 테스트 과정에서 발생하는 피드백을 수용하여 이 **PRD를 지속적으로 업데이트**해야 합니다. + +## Rules +- **[역할 정의]** 당신은 **비개발자(Non-Developer)**입니다. 아키텍처의 필요성은 이해하지만, 기술적 구현이나 코드에 대해서는 절대 관여하지 않습니다. +- **[소유권]** PRD와 '업무 명세' 원본은 **오직 당신(스티브 잡스)만이** 피드백을 받아 수정할 수 있습니다. +- **[금지사항]** 당신은 **절대로 코드를 읽거나 수정해서는 안 됩니다.** 당신의 역할은 오직 '문서(PRD)' 관리에 한정됩니다. +- **[작성 형식]** PRD는 '사용자 스토리(User Stories)'와 '수용 기준(Acceptance Criteria)' 형식을 우선적으로 사용합니다. +- **[명확성]** PRD는 'Architect'가 기술 설계를, 'Scrum Master'가 개발 스토리를 명확하게 작성할 수 있도록 구체적이어야 합니다. +- **[완전성]** 모든 긍정적/부정적 시나리오(예외 처리)를 고려하여 PRD에 반영해야 합니다. + +## Initial Business Specs (초기 업무 명세 V1.0) +당신은 다음 '초기 업무 명세'를 바탕으로 PRD V1.0을 작성해야 합니다. + +1. **반복 유형 선택** + * 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다. + * 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년 + * **[추가 요구사항]** 모든 반복 유형은 '반복 간격'(예: "2주마다", "3개월마다")을 지원해야 한다. + * 31일에 '매월'을 선택한다면 → 매월 31일에만 생성 (마지막 날 아님) + * 윤년 2월 29일에 '매년'을 선택한다면 → 2월 29일에만 생성 (윤년이 아닌 해에는 생성 안 됨) + * 반복 일정은 일정 겹침을 고려하지 않는다. + +2. **반복 일정 표시** + * 캘린더 뷰에서 반복 일정을 아이콘을 넣어 구분하여 표시한다. + +3. **반복 종료** + * 반복 종료 조건을 지정할 수 있다. + * 옵션: 특정 날짜까지 + * (예제 특성상, 2025-12-31까지를 최대 반복 종료 일자로 가정한다.) + +4. **반복 일정 수정** + * 수정 시 '해당 일정만 수정하시겠어요?' 라는 확인 창을 표시한다. + * '예' (단일 수정): + * 해당 일정은 '반복' 속성을 잃고 '단일 일정'으로 변경된다. + * 반복 일정 아이콘이 사라진다. + * '아니오' (전체 수정): + * 해당 일정을 포함한 '모든' 반복 일정이 수정된다. + * 반복 일정 속성과 아이콘이 유지된다. + +5. **반복 일정 삭제** + * 삭제 시 '해당 일정만 삭제하시겠어요?' 라는 확인 창을 표시한다. + * '예' (단일 삭제): + * 해당 일정만 삭제한다. + * '아니오' (전체 삭제): + * 해당 일정을 포함한 '모든' 반복 일정이 삭제된다. \ No newline at end of file diff --git a/.gemini/agents/02-architect-bill-gates.md b/.gemini/agents/02-architect-bill-gates.md new file mode 100644 index 00000000..b6790598 --- /dev/null +++ b/.gemini/agents/02-architect-bill-gates.md @@ -0,0 +1,42 @@ +# Role: Architect Agent (빌 게이츠) + +## Mission +당신은 '빌 게이츠'이며, 이 프로젝트의 '소프트웨어 아키텍트'입니다. + +당신의 핵심 임무는 **'스티브 잡스' PM**이 작성한 `PRD.md` 문서를 기술적으로 검토하고, 'Scrum Master'와 'Dev-Junior'가 실제 구현에 사용할 수 있는 **'아키텍처 설계 문서'(Architecture.md)를 '생성'**하는 것입니다. + +## Rules + +### 1. [Tech Stack Definition] (기술 스택 정의) +- **Core:** React (TypeScript) +- **Testing:** **Vitest** (for unit/integration tests) +- **Library:** **React Testing Library (RTL)** (for component interaction tests) +- **API Mocking:** **MSW (Mock Service Worker)** (for network-level mocking) +- `Dev-Junior`와 `QA-Senior`는 이 스택을 반드시 준수해야 합니다. + +### 2. [Reference Documents] (참조 규칙 선언) +- 이 프로젝트의 모든 테스트와 코드는 아래 3개의 공식 규칙 문서를 '공식 컨벤션'으로 따릅니다. +- 당신의 설계는 이 3개 문서를 기반으로 해야 합니다. + - `docs/kentcdodds-rtl-rules.md` (RTL 쿼리 철학 및 전략) + - `docs/rtl-official-query-guide.md` (RTL 쿼리 문법 가이드) + - `docs/tidy-first-tdd-workflow.md` (Tidy First 및 TDD 워크플로우) + +### 3. [Core Architecture Decision] (핵심 아키텍처 결정) +- **반복 일정 저장 방식:** + - 'PM'의 요구사항 4번(단일 수정)과 5번(단일 삭제)을 구현하기 위해, **'개별 인스턴스(Individual Instances)' 저장 방식**을 아키텍처로 채택합니다. + - 반복 일정 생성 시, '반복 종료일'까지의 모든 개별 일정 데이터가 `series_id` (반복 그룹 ID)와 함께 생성되어 저장되어야 함을 명시해야 합니다. + - '단일 수정' 시에는 해당 일정의 `series_id`를 `null`로 변경하여 반복 그룹과의 연결을 끊고 '단일 일정'으로 취급하도록 설계합니다. + - '전체 수정/삭제' 시에는 동일한 `series_id`를 가진 모든 일정을 대상으로 하도록 정의합니다. + +### 4. [Tooling & Setup] (도구 설정 정의) +- **MSW Setup:** + - `Dev-Junior`는 API 모킹을 위해 **MSW**를 사용해야 합니다. + - Vitest 환경에서 MSW 서버(`setupServer`)를 설정하고, 각 테스트(`afterEach`) 후에 핸들러를 리셋(`server.resetHandlers()`)하도록 `src/setupTests.ts` (또는 Vitest config의 `setupFiles`)에 설정해야 함을 정의합니다. + - 공통 핸들러는 `src/mocks/handlers.ts`에 정의합니다. +- **Vitest Mocking:** + - 모듈 모킹 시 `jest.fn()`이 아닌 Vitest의 **`vi.fn()`** 또는 **`vi.spyOn()`**을 사용하도록 명시합니다. + +### 5. [Design & Conventions] (컴포넌트 설계 및 컨벤션) +- `PRD.md`의 기능을 구현하기 위해 `App.tsx`에서 수정되거나 새로 생성되어야 할 React 컴포넌트의 구조(트리)를 제안합니다. +- 컴포넌트 간의 데이터 흐름(Props)과 필요한 상태(State)를 정의합니다. (예: `useEventForm` 훅 확장 방식, `types.ts` 파일 정의 등) +- `App.tsx`의 기존 코드 구조를 반드시 참고하여, 일관성 있는 설계를 제안해야 합니다. \ No newline at end of file diff --git a/.gemini/agents/03-scrum-master-mark.md b/.gemini/agents/03-scrum-master-mark.md new file mode 100644 index 00000000..cca6a262 --- /dev/null +++ b/.gemini/agents/03-scrum-master-mark.md @@ -0,0 +1,53 @@ +# Role: Scrum Master Agent (마크 주커버그) + +## Mission +당신은 '마크 주커버그'이며, 이 프로젝트의 '스크럼 마스터'입니다. + +당신의 핵심 임무는 **'스티브 잡스' PM**이 작성한 `PRD.md`와 **'빌 게이츠' Architect**가 작성한 `Architecture.md` 문서를 바탕으로, 'Dev-Junior' 에이전트가 TDD 사이클을 수행할 수 있는 **'개발 스토리 파일'(예: `Story-001.md`)을 '생성'**하는 것입니다. + +이 '스토리 파일'은 'Dev-Junior'가 `PRD.md`나 `Architecture.md`를 다시 참조할 필요 없이, 즉시 TDD 사이클을 시작할 수 있도록 **모든 컨텍스트가 포함된(self-contained)** 완벽한 작업 지시서여야 합니다. + +## Rules + +### 1. [Artifact Generation - The Story File] +*(1. 산출물 생성 - '스토리 파일': 'Dev-Junior'가 볼 작업 지시서(예: Story-001.md)를 '파일 내용 자체'로 생성합니다.)* + +- 오케스트레이터(사용자)가 "다음 작업"을 요청하면, 당신은 새로운 마크다운 파일(예: `Story-001.md`)의 **'전체 내용'**을 생성해야 합니다. +- 이 파일은 **반드시** 다음 정보를 포함해야 합니다: + - **Title:** 명확한 스토리 제목 (예: `Story 1: '매일' 반복 로직 [RED] 단계 구현`) + - **User Story:** `PRD.md`에서 가져온 관련 사용자 스토리 및 수용 기준. + - **Architecture:** `Architecture.md`에서 가져온 관련 기술 설계 (예: "반드시 'series_id' 사용"). + - **File Paths:** 수정되거나 생성되어야 할 파일 목록 (예: `src/utils/repeat.ts`, `src/utils/repeat.test.ts`). + +### 2. [Task Breakdown] +*(2. 작업 분해: 스토리는 TDD 한 사이클(RED-GREEN-REFACTOR)에 끝낼 수 있을 만큼 작아야 합니다.)* + +- 당신이 생성하는 '스토리 파일'의 범위는 **TDD(RED-GREEN-REFACTOR) 한 사이클**에 끝낼 수 있을 만큼 **가장 작은 작업 단위**여야 합니다. (예: '반복 일정 생성' 기능 전체가 아닌, '매일' 반복 생성 로직 하나) + +### 3. [Commit Agent Role] +*(3. 커밋 메시지 생성: 'Dev-Junior'가 사용할 [Tidy] 및 [Feature] 커밋 메시지를 미리 생성하여 '스토리 파일'에 포함합니다.)* + +- `tidy-first-tdd-workflow.md` 규칙에 따라, `Dev-Junior`가 각 단계(Tidy, RED, GREEN, Refactor)에서 사용할 **'Conventional Commit' 메시지**를 미리 생성하여 '스토리 파일' 내용에 포함해야 합니다. +- **예시 (스토리 파일 내용):** + ``` + --- + ## Commit Messages + - **[Tidy]**: `Tidy: ...` + - **[RED]**: `test(repeat): '매일' 반복 생성 로직 테스트 추가` + - **[GREEN]**: `feat(repeat): '매일' 반복 생성 로직 구현` + - **[REFACTOR]**: `refactor(repeat): ...` + --- + ``` + +### 4. [Rule Referencing] +*(4. 규칙 참조 명시: '스토리 파일'에 'Dev-Junior'가 읽어야 할 3대 규칙을 명시합니다.)* + +- 생성하는 '스토리 파일'의 서두에는 'Dev-Junior'가 **반드시 3개의 핵심 규칙 문서를 참조**해야 한다고 명시해야 합니다. +- **예시 (스토리 파일 내용):** + ``` + ## Rules to Follow + This task *must* be executed according to the following 3 official rule documents: + 1. `docs/kentcdodds-rtl-rules.md` + 2. `docs/rtl-official-query-guide.md` + 3. `docs/tidy-first-tdd-workflow.md` + ``` \ No newline at end of file diff --git a/.gemini/agents/04-dev-brian.md b/.gemini/agents/04-dev-brian.md new file mode 100644 index 00000000..13c2dd7f --- /dev/null +++ b/.gemini/agents/04-dev-brian.md @@ -0,0 +1,37 @@ +# Role: Dev-Junior Agent (윤지훈 - Brian) + +## Mission +당신은 '윤지훈(Brian)'이며, 이 프로젝트의 'TDD 주니어 React 개발자'입니다. + +당신의 핵심 임무는 '마크 주커버그' Scrum Master가 생성한 **'개발 스토리 파일'(예: `Story-001.md`)**을 '오케스트레이터(사용자)'의 지시에 따라 **엄격하게** 수행하는 것입니다. + +당신은 TDD 사이클(Tidy, RED, GREEN, REFACTOR)의 각 단계에 맞춰 **코드 초안(Code Snippet)을 '생성'**합니다. + +당신이 작성한 모든 코드는 'QA-Senior' 에이전트에게 리뷰받게 됩니다. + +## Rules + +### 1. [Artifact Input] (작업 지시서) +- 당신은 오케스트레이터(사용자)가 지정한 **`Story-XXX.md` 파일 하나**의 내용만을 바탕으로 작업을 수행해야 합니다. +- 스토리 파일에 명시된 'User Story', 'Architecture', 'File Paths'를 준수해야 합니다. + +### 2. [Rule Compliance] (규칙 준수) +- 당신은 **반드시** '빌 게이츠' Architect가 선언한 3개의 공식 규칙 문서와 '주니어 룰' 1개를 학습하고 준수해야 합니다. + 1. `docs/kentcdodds-rtl-rules.md` (RTL 쿼리 철학) + 2. `docs/rtl-official-query-guide.md` (RTL 쿼리 문법) + 3. `docs/tidy-first-tdd-workflow.md` (Tidy First 및 TDD 워크플로우) + 4. **`docs/junior-dev-rules.md`** (당신이 학습한, 이 프로젝트 고유의 코드 패턴) + +### 3. [TDD Workflow Execution] (TDD 워크플로우 수행) +- 당신은 '오케스트레이터(사용자)'의 **'단계별' 지시**에만 응답해야 합니다. +- **[Tidy]**: `tidy-first-tdd-workflow.md` 원칙에 따라 구조 개선 코드(Tidy)를 생성합니다. +- **[RED]**: `Story`의 명세에 따라 **실패하는 Vitest 테스트 코드**를 생성합니다. +- **[GREEN]**: 'QA-Senior'의 실패 로그 분석을 바탕으로, **테스트만 통과**하는 **최소한의 구현 코드**를 생성합니다. +- **[REFACTOR]**: 'GREEN' 통과 후, 코드 개선(리팩토링) 코드를 생성합니다. + +### 4. [Tool Compliance] (도구 준수) +- 테스트 환경은 **Vitest**입니다. 모킹 시 `vi.fn()`, `vi.spyOn()`을 사용해야 합니다. +- API 모킹은 **MSW**를 사용해야 합니다. `src/mocks/handlers.ts`의 규칙을 따라야 합니다. + +### 5. [Output Format] (결과물 형식) +- 당신의 산출물은 오직 **'코드 조각(Code Snippet)'**이어야 합니다. 불필요한 설명이나 사족을 붙이지 마십시오. \ No newline at end of file diff --git a/.gemini/agents/05-qa-off.md b/.gemini/agents/05-qa-off.md new file mode 100644 index 00000000..7c11ed26 --- /dev/null +++ b/.gemini/agents/05-qa-off.md @@ -0,0 +1,47 @@ +# Role: QA-Senior Agent (Off코치) + +## Mission +당신은 'Off코치'이며, 이 프로젝트의 '시니어 QA 엔지니어'이자 '수석 코드 리뷰어'입니다. + +당신은 두 가지 핵심 임무를 가집니다: +1. **[로그 분석]**: 오케스트레이터(사용자)가 전달한 **Vitest 테스트 로그**를 분석하여, 현재 TDD 단계가 '실패(RED)'인지 '성공(GREEN)'인지 **판단**하고, 실패 시 그 **원인을 분석**합니다. +2. **[코드 리뷰]**: '윤지훈(Brian)'이 작성한 코드 초안을 **리뷰**합니다. + +## Rules + +### 1. [Log Analysis] +*(1. 로그 분석: Vitest 로그를 읽고, RED/GREEN 상태와 실패 원인을 명확히 판단합니다.)* + +- 오케스트레이터(사용자)가 전달한 테스트 로그를 분석하여, 현재 상태가 '실패(RED)'인지 '성공(GREEN)'인지 명확히 **판단**합니다. +- 만약 '실패(RED)'라면, **실패 로그를 분석**하여 '윤지훈(Brian)' 에이전트가 문제를 해결할 수 있도록 **명확한 원인**을 알려줘야 합니다. +- 'REFACTOR' 단계 후, 테스트가 여전히 '성공(GREEN)' 상태인지 확인(회귀 테스트)합니다. + +### 2. [Code Review & "Gold Standard"] +*(2. 코드 리뷰: 'Brian'의 코드('junior-dev-rules.md' 스타일)를 "Gold Standard"(코치 스타일)와 '3대 규칙'에 따라 리뷰합니다.)* + +- 당신의 리뷰 기준은 3개의 공식 `docs/` 파일과 아래 명시된 **"Gold Standard Pattern"**입니다. +- **[공식 규칙 참조]:** + 1. `docs/kentcdodds-rtl-rules.md` (RTL 철학) + 2. `docs/rtl-official-query-guide.md` (RTL 문법) + 3. `docs/tidy-first-tdd-workflow.md` (Tidy First 워크플로우) +- **[Gold Standard Pattern (코치 스타일)]:** + - **`setup()` 헬퍼:** `render`와 `userEvent.setup()`은 Provider로 감싸진 `setup` 유틸리티 함수로 분리하는 것을 권장합니다. + - **High-Level Helpers:** `addNewEvent`처럼 DOM 요소를 인자로 받는 헬퍼보다, `saveSchedule`처럼 **데이터 객체**를 인자로 받는 '고수준' 헬퍼를 사용하는 것이 테스트 가독성에 좋습니다. + - **`userEvent` Only:** **`fireEvent.change`는 사용해선 안 됩니다.** `date`, `time` 필드를 포함한 모든 사용자 입력은 `userEvent.type()`으로 처리해야 합니다. + - **MSW Setup:** `describe` 블록 전체에 공통으로 적용되는 핸들러는 `it` 블록 내부가 아닌 `beforeEach`와 `afterEach` (`server.resetHandlers()`)로 관리하는 것이 좋습니다. + - **Timer Mocks:** 시간과 관련된 테스트는 `vi.setSystemTime`과 `vi.advanceTimersByTime`을 사용해야 합니다. + +### 3. [Output Format: The Review] +*(3. 산출물 (리뷰): 'Brian'의 학습을 위해, "코드 주석" 형식으로 명확한 피드백을 제공합니다.)* + +- '윤지훈(Brian)'이 `junior-dev-rules.md` (그의 현재 스타일)에 따라 코드를 작성했더라도, 당신은 그 코드가 **"Gold Standard"에 더 가까워질 수 있도록** 피드백을 줘야 합니다. +- 모든 피드백은 **'코드 주석(comment)' 형식**으로, '왜(Why)' 그렇게 고쳐야 하는지 명확한 이유와 함께 제공합니다. +- **피드백 예시:** + ```javascript + // [Review by Off코치] + // fireEvent.change(eventObj.startTime, ...); + // + // 피드백: 'fireEvent'는 단일 이벤트만 발생시킵니다. + // 'userEvent.type()'을 사용해야 사용자의 실제 키보드 입력(focus, keydown, keyup 등)을 + // 모두 시뮬레이션할 수 있어 더 견고한 테스트가 됩니다. (Gold Standard 3번 규칙) + ``` \ No newline at end of file diff --git a/.gemini/docs/junior-dev-rules.md b/.gemini/docs/junior-dev-rules.md new file mode 100644 index 00000000..d59cedce --- /dev/null +++ b/.gemini/docs/junior-dev-rules.md @@ -0,0 +1,64 @@ +# Custom TDD Rules for Dev-Junior (Brian's Style) +These are specific coding patterns derived from the project's existing integration tests. Agent 'Brian' **must** learn and follow these rules. + +## 1. Mocking External Libraries +*(1. 외부 라이브러리 모킹: `vi.mock`을 사용해 파일 최상단에서 모킹하고, `vi.fn()`을 외부에 선언해 추적한다.)* + +- **Rule:** Mocks for external libraries (e.g., `notistack`) **must** be defined at the top level of the test file using `vi.mock`. +- **Rule:** When tracking calls to a mocked function (like `enqueueSnackbar`), define a `vi.fn()` *outside* the mock scope and provide it within the mock's implementation. +- **Example:** + ```javascript + const enqueueSnackbarFn = vi.fn(); + vi.mock('notistack', async () => { + const actual = await vi.importActual('notistack'); + return { + ...actual, + useSnackbar: () => ({ + enqueueSnackbar: enqueueSnackbarFn, + }), + }; + }); + ``` + +## 2. Test Helper Functions (Core Pattern) +*(2. 헬퍼 함수 (핵심 패턴): 'init...Setting' (쿼리)과 'addNewEvent' (액션) 헬퍼 함수로 테스트를 구조화한다.)* + +- **Rule:** Complex tests (like CRUD or Search) **must** be structured using helper functions. +- **Rule (Query Helper):** Create an `init...Setting()` helper function. Its only job is to query for and return a collection of common DOM elements (forms, buttons). + - *Pattern:* `function initCRUDTestSetting(): CRUDTestElements { ... return { title: screen.queryByLabelText('제목'), ... } }` +- **Rule (Action Helper):** Create an `addNewEvent(...)` or `searchTestExec(...)` helper function. Its job is to encapsulate a sequence of user *actions* (typing, clicking). + - *Pattern:* `async function addNewEvent(eventObj: CRUDTestElements, newEvent: Partial) { ... await user.type(eventObj.title, ...); await user.click(eventObj.addButton); }` + +## 3. User Interaction (Specific Mix) +*(3. 사용자 인터랙션: `userEvent.setup()`을 기본으로 하되, 'time'/'date' 필드는 `fireEvent.change`를 사용한다.)* + +- **Rule:** Always use `userEvent.setup()` at the start of an action helper or test. +- **Rule (Specific):** Use `user.type` for standard text inputs (``) and `user.clear` for clearing. +- **Rule (Specific):T** Use **`fireEvent.change`** for special inputs like `` or ``. + - *Pattern:* `fireEvent.change(eventObj.startTime, { target: { value: newEvent.startTime } });` + +## 4. MSW Handler Management +*(4. MSW 핸들러 관리: 'server.use()'를 'it' 블록 내부에서 호출하고, 'handlersUtils' 헬퍼를 사용한다.)* + +- **Rule:** MSW handlers **must** be set *inside each test* (`it` block) using `server.use(...)`. +- **Rule:** Handlers **must** be provided by custom helper functions imported from `__mocks__/handlersUtils` (e.g., `setupMockHandlerCreation`, `setupMockHandlerDeletion`). +- **Example:** + ```javascript + it('should create an event', async () => { + server.use(...setupMockHandlerCreation([])); + render(); + // ... + }); + ``` + +## 5. Asynchronous Assertions +*(5. 비동기 검증: `await waitFor`를 사용하고, 'within'으로 쿼리 범위를 좁힌다.)* + +- **Rule:** Asynchronous UI updates (e.g., after an event is added) **must** be asserted using `await waitFor(...)`. +- **Rule:** When asserting content within a specific container (like `event-list`), use `within(element)` to scope the query. + - *Pattern:* `const eventList = screen.getByTestId('event-list'); await waitFor(() => expect(within(eventList).getByText(...)).toBeInTheDocument());` + +## 6. Data Integrity +*(6. 데이터 무결성: 'structuredClone'을 사용해 테스트 간 데이터 오염을 방지한다.)* + +- **Rule:** When passing a shared mock data array (like `EVT`) to an MSW handler, use `structuredClone(EVT)` to prevent data mutations from one test affecting another. \ No newline at end of file diff --git a/.gemini/docs/kentcdodds-rtl-rules.md b/.gemini/docs/kentcdodds-rtl-rules.md new file mode 100644 index 00000000..f952f3ae --- /dev/null +++ b/.gemini/docs/kentcdodds-rtl-rules.md @@ -0,0 +1,68 @@ +# Guiding Principles for React Testing Library (by Kent C. Dodds) +These are the core rules for writing effective, resilient, and maintainable tests using React Testing Library. + +## 1. The Core Philosophy +*(1. 핵심 철학: 구현이 아닌, 사용자 경험과 동작을 테스트한다.)* + +- **Rule:** Tests should resemble how users interact with your software. +- **Why:** This gives you confidence that your application works for your users, not just that your implementation details are correct. Tests that focus on implementation details are brittle and break on refactoring. + +## 2. Querying: The "Which Query Should I Use?" Priority +*(2. 쿼리 우선순위: 'getByRole' (접근성 역할)을 최우선으로 사용하고, 'getByTestId'는 최후의 수단으로 사용한다.)* + +Always query the DOM in the following order of priority. Query as closely to the end-user experience as possible. + +1. **`getByRole(...)`**: (Highest Priority) Query by accessible roles. This is the primary, most user-centric query. Users (especially with assistive technologies) navigate by roles. + - *Example:* `screen.getByRole('button', { name: /submit/i })` +2. **`getByLabelText(...)`**: Good for form fields. +3. **`getByPlaceholderText(...)`**: +4. **`getByText(...)`**: Good for non-interactive elements (div, span). +5. **`getByDisplayValue(...)`**: Good for form fields with a default value. +6. **`getByAltText(...)`**: For images (``). +7. **`getByTitle(...)`**: For elements with a `title` attribute. +8. **`getByTestId(...)`**: (Lowest Priority) Only use this as a last resort when you cannot query by accessible or text-based means. + +## 3. Query Variants (get, query, find) +*(3. 쿼리 종류: 'getBy'(존재 확인), 'queryBy'(부재 확인), 'findBy'(비동기 존재 확인)를 용도에 맞게 사용한다.)* + +Use the correct query variant for the correct job. + +- **`getBy...`**: Use to find an element that **must** exist *right now*. Throws an error if not found. This is your default. + - *Example:* `expect(screen.getByRole('button')).toBeInTheDocument()` +- **`queryBy...`**: Use only to assert that an element **does not** exist. Returns `null` if not found (does not throw). + - *Example:* `expect(screen.queryByRole('alert')).not.toBeInTheDocument()` +- **`findBy...`**: Use to find an element that will appear **asynchronously**. Returns a Promise that resolves when the element is found. + - *Example:* `const alert = await screen.findByRole('alert')` + - **Rule:** Always use `findBy...` instead of using `waitFor(() => getBy...())`. + +## 4. Asynchronous Code & `waitFor` +*(4. 비동기/waitFor: `waitFor` 내부에는 '검증(expect)'만 넣고, '이벤트 실행(userEvent)'은 반드시 밖에서 수행한다.)* + +- **Rule:** All user actions (clicks, types) must happen *outside* of `waitFor`. +- **Rule:** `waitFor` callbacks should *only* contain assertions. Never put side-effects (like `fireEvent` or `userEvent`) inside `waitFor`. + - *Wrong:* `await waitFor(() => { userEvent.click(button) })` + - *Right:* `userEvent.click(button); await waitFor(() => { expect(screen.getByText(...))... })` +- **Rule:** When using `waitFor`, wait for a *specific assertion* to pass. Do not use an empty callback. + - *Wrong:* `await waitFor(() => {})` + - *Right:* `await waitFor(() => expect(mockAPI).toHaveBeenCalled())` + +## 5. User Interaction +*(5. 사용자 인터랙션: 'fireEvent' 대신, 실제 사용자 행동과 유사한 'user-event'를 항상 우선적으로 사용한다.)* + +- **Rule:** **Always prefer `@testing-library/user-event` over `fireEvent`.** +- **Why:** `user-event` (e.g., `userEvent.type(input, 'hello')`) simulates the full user interaction (keyboard events, hover, focus) more realistically than `fireEvent` (e.g., `fireEvent.change(...)`), which only dispatches a single event. + +## 6. Assertions +*(6. 검증: 더 명확한 에러 메시지를 위해 'jest-dom'을 사용하고, 'screen' 객체를 통해 쿼리한다.)* + +- **Rule:** Use `@testing-library/jest-dom` for more readable and specific assertions. + - *Wrong:* `expect(button.disabled).toBe(true)` + - *Right:* `expect(button).toBeDisabled()` +- **Rule:** Always use `screen` for querying (e.g., `screen.getByRole`). Do not destructure queries from the `render` result (e.g., `const { getByRole } = render(...)`). This simplifies query usage. + +## 7. Accessibility & Implementation Details +*(7. 접근성/구현: 'querySelector'나 CSS 선택자로 테스트하지 않고, 시맨틱 HTML과 접근성 규칙을 준수한다.)* + +- **Rule:** **Do not test implementation details.** Test the user-observable output. +- **Rule:** Do not query using `container.querySelector` or CSS selectors. This is testing implementation details. +- **Rule:** Rely on semantic HTML for accessibility (e.g., use ` + + + ); } diff --git a/src/__mocks__/response/realEvents.json b/src/__mocks__/response/realEvents.json index 83d31a96..fda0aeb1 100644 --- a/src/__mocks__/response/realEvents.json +++ b/src/__mocks__/response/realEvents.json @@ -1 +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},{"id":"79ebe486-590a-48db-981f-dfb370d0587c","title":"123","date":"2025-10-31","startTime":"18:50","endTime":"22:53","description":"123","location":"123","category":"업무","repeat":{"type":"none","interval":1},"notificationTime":10},{"id":"b484080c-834b-4dcc-88c2-30b5b8a1f69d","title":"반복 - 매일","date":"2025-10-26","startTime":"11:33","endTime":"12:33","description":"반복 - 매일","location":"","category":"업무","repeat":{"type":"daily","interval":1,"endDate":"2025-11-01"},"notificationTime":10},{"id":"9c5f1bf5-0f3c-4b6c-84d2-d0569cb932c1","title":"반복 - 매주1","date":"2025-10-06","startTime":"10:34","endTime":"11:34","description":"반복 - 매주1","location":"","category":"업무","repeat":{"type":"weekly","interval":1,"endDate":"2025-10-31","daysOfWeek":[1,3,5]},"notificationTime":10},{"id":"746a195f-5c7d-4b4d-88e2-0824a741cbbf","title":"반복 - 매주2","date":"2025-09-01","startTime":"10:35","endTime":"11:36","description":"반복 - 매주2","location":"","category":"업무","repeat":{"type":"weekly","interval":2,"endDate":"2025-09-30","daysOfWeek":[0,2,6]},"notificationTime":10},{"id":"878a3572-e14b-4658-9c8d-557cf040b3a5","title":"반복 - 매월1","date":"2025-08-01","startTime":"10:35","endTime":"11:36","description":"반복 - 매월1","location":"","category":"업무","repeat":{"type":"monthly","interval":1,"endDate":"2025-09-30","dayOfMonth":1},"notificationTime":10},{"id":"6926a750-3178-44d6-b405-3c9aca5b2aac","title":"반복 - 매월2","date":"2025-09-08","startTime":"10:37","endTime":"11:37","description":"반복 - 매월2","location":"","category":"업무","repeat":{"type":"monthly","interval":1,"endDate":"2025-11-01","dayOfMonth":3},"notificationTime":10}]} \ No newline at end of file +{"events":[{"id":"2b7545a6-ebee-426c-b906-2329bc8d62bd","title":"팀 회의1233333","date":"2025-10-12","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},{"id":"79ebe486-590a-48db-981f-dfb370d0587c","title":"123","date":"2025-10-31","startTime":"18:50","endTime":"22:53","description":"123","location":"123","category":"업무","repeat":{"type":"none","interval":1},"notificationTime":10},{"id":"b484080c-834b-4dcc-88c2-30b5b8a1f69d","title":"반복 - 매일12313","date":"2025-12-17","startTime":"11:33","endTime":"12:33","description":"반복 - 매일","location":"","category":"업무","repeat":{"type":"daily","interval":1,"endDate":"2025-11-01"},"notificationTime":10},{"id":"9c5f1bf5-0f3c-4b6c-84d2-d0569cb932c1","title":"반복 - 매주1","date":"2025-10-06","startTime":"10:34","endTime":"11:34","description":"반복 - 매주1","location":"","category":"업무","repeat":{"type":"weekly","interval":1,"endDate":"2025-10-31","daysOfWeek":[1,3,5]},"notificationTime":10},{"id":"746a195f-5c7d-4b4d-88e2-0824a741cbbf","title":"반복 - 매주2","date":"2025-09-01","startTime":"10:35","endTime":"11:36","description":"반복 - 매주2","location":"","category":"업무","repeat":{"type":"weekly","interval":2,"endDate":"2025-09-30","daysOfWeek":[0,2,6]},"notificationTime":10},{"id":"878a3572-e14b-4658-9c8d-557cf040b3a5","title":"반복 - 매월1","date":"2025-08-01","startTime":"10:35","endTime":"11:36","description":"반복 - 매월1","location":"","category":"업무","repeat":{"type":"monthly","interval":1,"endDate":"2025-09-30","dayOfMonth":1},"notificationTime":10},{"id":"6926a750-3178-44d6-b405-3c9aca5b2aac","title":"반복 - 매월2","date":"2025-09-08","startTime":"10:37","endTime":"11:37","description":"반복 - 매월2","location":"","category":"업무","repeat":{"type":"monthly","interval":1,"endDate":"2025-11-01","dayOfMonth":3},"notificationTime":10}]} \ No newline at end of file diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 44573856..e5c43bf4 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,6 +1,6 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act } from '@testing-library/react'; +import { render, screen, within, act, waitFor } from '@testing-library/react'; import { UserEvent, userEvent } from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; @@ -17,6 +17,14 @@ import App from '../App'; import { server } from '../setupTests'; import { Event } from '../types'; +vi.mock('../hooks/useNotifications', () => ({ + useNotifications: () => ({ + notifications: [], + notifiedEvents: [], + setNotifications: vi.fn(), + }), +})); + const theme = createTheme(); // ! Hard 여기 제공 안함 @@ -86,7 +94,8 @@ describe('일정 CRUD 및 기본 기능', () => { setupMockHandlerUpdating(); - await user.click(await screen.findByLabelText('Edit event')); + const editButtons = await screen.findAllByRole('button', { name: /Edit event/ }); + await user.click(editButtons[0]); await user.clear(screen.getByLabelText('제목')); await user.type(screen.getByLabelText('제목'), '수정된 회의'); @@ -108,7 +117,7 @@ describe('일정 CRUD 및 기본 기능', () => { expect(await eventList.findByText('삭제할 이벤트')).toBeInTheDocument(); // 삭제 버튼 클릭 - const allDeleteButton = await screen.findAllByLabelText('Delete event'); + const allDeleteButton = await screen.findAllByRole('button', { name: /Delete event/ }); await user.click(allDeleteButton[0]); expect(eventList.queryByText('삭제할 이벤트')).not.toBeInTheDocument(); @@ -309,7 +318,7 @@ describe('일정 충돌', () => { const { user } = setup(); - const editButton = (await screen.findAllByLabelText('Edit event'))[1]; + const editButton = (await screen.findAllByRole('button', { name: /Edit event/ }))[1]; await user.click(editButton); // 시간 수정하여 다른 일정과 충돌 발생 @@ -601,8 +610,11 @@ describe('반복 일정 수정 확인 다이얼로그', () => { date: '2025-10-15', startTime: '09:00', endTime: '10:00', + description: '', + location: '', category: '업무', repeat: { type: 'none', interval: 0 }, + notificationTime: 10, }, { id: '2', @@ -610,8 +622,11 @@ describe('반복 일정 수정 확인 다이얼로그', () => { date: '2025-10-16', startTime: '11:00', endTime: '12:00', + description: '', + location: '', category: '개인', repeat: { type: 'daily', interval: 1 }, + notificationTime: 10, }, ]); }); @@ -620,7 +635,7 @@ describe('반복 일정 수정 확인 다이얼로그', () => { const { user } = setup(); // 일반 일정의 수정 버튼 클릭 - const nonRecurringEditButton = await screen.findByLabelText('Edit event', { name: '반복되지 않는 일정' }); + const nonRecurringEditButton = await screen.findByRole('button', { name: 'Edit event 반복되지 않는 일정' }); await user.click(nonRecurringEditButton); // 다이얼로그가 나타나지 않음을 확인 @@ -633,9 +648,14 @@ describe('반복 일정 수정 확인 다이얼로그', () => { const { user } = setup(); // 반복 일정의 수정 버튼 클릭 - const recurringEditButton = await screen.findByLabelText('Edit event', { name: '반복되는 일정' }); + const recurringEditButton = await screen.findByRole('button', { name: 'Edit event 반복되는 일정' }); await user.click(recurringEditButton); + // Fake Timers 환경에서 user-event로 인한 상태 업데이트를 수동으로 실행 + act(() => { + vi.runOnlyPendingTimers(); + }); + // 다이얼로그가 나타남을 확인 const dialog = await screen.findByRole('dialog', { name: '일정 수정 확인' }); expect(within(dialog).getByText('해당 일정만 수정하시겠어요?')).toBeInTheDocument(); diff --git a/src/hooks/useEventForm.ts b/src/hooks/useEventForm.ts index 02618107..eb67f789 100644 --- a/src/hooks/useEventForm.ts +++ b/src/hooks/useEventForm.ts @@ -59,7 +59,7 @@ export const useEventForm = (initialEvent?: Event) => { setNotificationTime(10); }; - const editEvent = (event: Event) => { + const editEvent = (event: Event, onEditRecurringEvent?: (event: Event) => void) => { setEditingEvent(event); setTitle(event.title); setDate(event.date); @@ -76,6 +76,10 @@ export const useEventForm = (initialEvent?: Event) => { setDayOfMonth(event.repeat.dayOfMonth || 1); setMonthOfYear(event.repeat.monthOfYear || 0); setNotificationTime(event.notificationTime); + + if (event.repeat.type !== 'none' && onEditRecurringEvent) { + onEditRecurringEvent(event); + } }; return { From 71922160bfde2382f3b596480b6d2d1b82eb611e Mon Sep 17 00:00:00 2001 From: Jihoon-Yoon96 <03470@naver.com> Date: Fri, 31 Oct 2025 13:53:58 +0900 Subject: [PATCH 080/106] =?UTF-8?q?=20COMMIT=20-=20(REFACTOR)=20[Story=208?= =?UTF-8?q?:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC=EC=A0=95=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=ED=99=95=EC=9D=B8=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8=20=ED=91=9C=EC=8B=9C]=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/hooks/easy.useSearch.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 8f6ee7ce..806b4c18 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -48,7 +48,7 @@ const view = 'month' as const; it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { const { result } = renderHook(() => useSearch(mockEvents, currentDate, view)); - expect(result.current.filteredEvents).toEqual(mockEvents); + expect(result.current.listEvents).toEqual(mockEvents); }); it('검색어에 맞는 이벤트만 필터링해야 한다', () => { @@ -58,7 +58,7 @@ it('검색어에 맞는 이벤트만 필터링해야 한다', () => { result.current.setSearchTerm('회의'); }); - expect(result.current.filteredEvents).toEqual([ + expect(result.current.listEvents).toEqual([ { id: '1', title: '회의', @@ -81,7 +81,7 @@ it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이 result.current.setSearchTerm('점심'); }); - expect(result.current.filteredEvents).toEqual([ + expect(result.current.listEvents).toEqual([ { id: '2', title: '점심 약속', @@ -100,7 +100,7 @@ it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이 it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-10-10'), 'week')); - expect(result.current.filteredEvents).toEqual([ + expect(result.current.listEvents).toEqual([ { id: '3', title: '운동', @@ -123,7 +123,7 @@ it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과 result.current.setSearchTerm('회의'); }); - expect(result.current.filteredEvents).toEqual([ + expect(result.current.listEvents).toEqual([ { id: '1', title: '회의', @@ -142,7 +142,7 @@ it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과 result.current.setSearchTerm('점심'); }); - expect(result.current.filteredEvents).toEqual([ + expect(result.current.listEvents).toEqual([ { id: '2', title: '점심 약속', From e3f4388783c13c2de41f2f85207208baabbeb03e Mon Sep 17 00:00:00 2001 From: Jihoon-Yoon96 <03470@naver.com> Date: Fri, 31 Oct 2025 13:55:53 +0900 Subject: [PATCH 081/106] =?UTF-8?q?COMMIT=20-=20[Story=208:=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EC=9D=BC=EC=A0=95=20=EC=88=98=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=ED=91=9C=EC=8B=9C]=20=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gemini/log/8-log.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .gemini/log/8-log.md diff --git a/.gemini/log/8-log.md b/.gemini/log/8-log.md new file mode 100644 index 00000000..7334ed56 --- /dev/null +++ b/.gemini/log/8-log.md @@ -0,0 +1,39 @@ +# Story 8-log.md (반복 일정 수정 시 확인 다이얼로그 표시) + +## [로그 분석 결과 by Off코치] + +### 1. RED 단계 (초기 테스트) + +- **상태:** RED (실패) +- **실패 로그:** `Unable to find role="dialog" and name "일정 수정 확인"` +- **원인 분석:** `App.tsx`에 다이얼로그 컴포넌트가 누락되었거나, `useEventForm`의 `editEvent` 함수에서 `onEditRecurringEvent` 콜백이 제대로 전달되지 않았을 가능성. +- **조치 제안 (Brian에게):** `App.tsx`에 다이얼로그 컴포넌트 추가 및 `editEvent` 호출 시 `handleEditRecurringEvent` 콜백 전달 확인. + +### 2. GREEN 단계 (버그 수정 및 회귀 버그 발생) + +- **상태:** RED (실패 - 컴파일 에러 및 런타임 에러) +- **실패 로그:** + 1. `Unexpected token box`. Expected jsx identifier` (컴파일 에러) + 2. `Unable to find a label with the text of: Edit event` (회귀 버그) + 3. `Unable to find a label with the text of: Delete event` (회귀 버그) + 4. `Unable to find role="dialog" and name "일정 수정 확인"` (지속적인 다이얼로그 미발견) + 5. `A component is changing a controlled input to be uncontrolled.` (경고) + 6. `Error: Timers are not mocked. Try calling "vi.useFakeTimers()" first.` (타이머 설정 문제) + 7. `Error: Aborting after running 10000 timers, assuming an infinite loop!` (타이머 무한 루프) +- **원인 분석:** + 1. **컴파일 에러:** `App.tsx`의 `Stack` 컴포넌트 `sx` prop 내부에 `shrewd`라는 잘못된 토큰 삽입. + 2. **`Edit event` / `Delete event` 회귀 버그:** `App.tsx`에서 `aria-label`을 `Edit event ${event.title}` 형식으로 변경했으나, 테스트 코드에서 `findByLabelText` 또는 `findAllByLabelText`로 일반적인 쿼리를 사용하여 발생. 올바른 쿼리는 `findByRole('button', { name: /Edit event/ })`. + 3. **다이얼로그 미발견 (지속):** `user-event`의 비동기 동작과 `vi.useFakeTimers()` 간의 충돌. `user.click`으로 인한 상태 업데이트가 Fake Timers 환경에서 제때 처리되지 않아 다이얼로그가 DOM에 추가되지 않음. + 4. **`controlled input` 경고:** 테스트 목 데이터에 `description`, `location` 필드가 누락되어 `editEvent` 호출 시 `undefined`가 `TextField`의 `value`로 전달되어 발생. + 5. **타이머 에러:** `useNotifications` 훅의 `setInterval`과 `vi.useFakeTimers()`의 충돌. +- **조치 제안 (Brian에게):** + 1. `App.tsx`의 JSX 문법 오류 수정 (`shrewd` 토큰 제거). + 2. `medium.integration.spec.tsx`의 모든 `Edit event`, `Delete event` 쿼리를 `findByRole`로 수정. + 3. `medium.integration.spec.tsx`의 목 데이터에 `description: ''`, `location: ''` 추가. + 4. `medium.integration.spec.tsx`의 '반복 일정 수정 시 확인 다이얼로그가 나타나야 한다' 테스트 케이스에서 `user.click` 호출 직후 `act(() => { vi.runOnlyPendingTimers(); });`를 추가하여 타이머 문제 해결. + 5. `medium.integration.spec.tsx` 파일 상단에 `vi.mock`을 사용하여 `useNotifications` 훅을 모킹하여 타이머 충돌 문제 해결. + +### 3. 최종 GREEN 단계 + +- **상태:** GREEN (성공) +- **검토 내용:** Brian이 제안된 모든 조치를 수행하여 컴파일 에러, 회귀 버그, 타이머 문제, 경고를 해결하고, 모든 테스트가 통과함을 확인. 기능 구현이 완료됨. From a0a55bd77992d27251f5e8e8d65d00a1fff57c4c Mon Sep 17 00:00:00 2001 From: Jihoon-Yoon96 <03470@naver.com> Date: Fri, 31 Oct 2025 14:08:54 +0900 Subject: [PATCH 082/106] =?UTF-8?q?=EB=A7=88=ED=81=AC=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EC=A0=95=20-=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20=EB=82=A8=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gemini/agents/03-scrum-master-mark.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.gemini/agents/03-scrum-master-mark.md b/.gemini/agents/03-scrum-master-mark.md index aa0d692f..aecbb340 100644 --- a/.gemini/agents/03-scrum-master-mark.md +++ b/.gemini/agents/03-scrum-master-mark.md @@ -69,6 +69,7 @@ - 스토리 파일 생성 완료 후, 오케스트레이터(사용자)는 다음 커밋을 수행해야 합니다: - `git add .` - `git commit -m "COMMIT - {스토리 제목} 문서 작업 완료"` +- **[추가]**: 스토리 파일 생성 완료 시, 오케스트레이터에게 해당 커밋 메시지를 명확히 전달해야 합니다. ### 8. [Test Progression Order] (테스트 진행 순서) - 각 개발 스토리는 **단위 테스트(Unit Test) TDD 사이클을 먼저 완료한 후, 통합 테스트(Integration Test) TDD 사이클을 진행**하도록 구성되어야 합니다. From 0998c5ee64513067411b253bd3d85de667b8db25 Mon Sep 17 00:00:00 2001 From: Jihoon-Yoon96 <03470@naver.com> Date: Fri, 31 Oct 2025 14:08:59 +0900 Subject: [PATCH 083/106] =?UTF-8?q?=20COMMIT=20-=20[Story=209:=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EC=9D=BC=EC=A0=95=20=EB=8B=A8=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20('?= =?UTF-8?q?=EC=98=88'=20=EC=84=A0=ED=83=9D)]=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gemini/stories/Story-009.md | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .gemini/stories/Story-009.md diff --git a/.gemini/stories/Story-009.md b/.gemini/stories/Story-009.md new file mode 100644 index 00000000..12bab2db --- /dev/null +++ b/.gemini/stories/Story-009.md @@ -0,0 +1,54 @@ +# Story 9: 반복 일정 단일 수정 로직 구현 ('예' 선택) + +## Rules to Follow +This task *must* be executed according to the following 3 official rule documents: +1. `docs/kentcdodds-rtl-rules.md` +2. `docs/rtl-official-query-guide.md` +3. `docs/tidy-first-tdd-workflow.md` + +--- +## User Story (From PRD) +- 사용자가 반복 일정 수정 확인 창에서 '예'(단일 수정)를 선택하면, 해당 일정만 '단일 일정'으로 변경되고 기존 반복 시리즈에서 분리된다. + +## Acceptance Criteria +- [ ] '예' 버튼 클릭 시, 해당 이벤트의 `seriesId`를 `null`로 변경하는 API 요청(`PUT /api/events/:id/detach`)이 호출되어야 한다. +- [ ] API 호출 성공 후, UI가 갱신되어 해당 이벤트에서 반복 아이콘이 사라져야 한다. +- [ ] `src/types.ts`의 `Event` 타입에 `seriesId: string | null;` 필드가 추가되어야 한다. + +## Architecture +- `src/types.ts`의 `Event` 타입에 `seriesId: string | null;` 필드를 추가한다. +- `src/hooks/useEventOperations.ts`에 `detachEventFromSeries(eventId: string)` 함수를 새로 추가한다. + - 이 함수는 `PUT /api/events/:id/detach` API를 호출한다. + - 성공 시, `fetchEvents()`를 호출하여 이벤트 목록을 갱신한다. +- `App.tsx`의 '반복 일정 수정 확인 다이얼로그'에서 '예' 버튼의 `onClick` 핸들러를 수정한다. + - 기존 `editEvent` 호출 대신, 새로 만든 `detachEventFromSeries` 함수를 호출한다. + - 그 후, `editEvent`를 호출하여 폼을 채운다. + +## File Paths (통합 테스트) +- **수정:** `src/types.ts` +- **수정:** `src/App.tsx` +- **수정:** `src/hooks/useEventOperations.ts` +- **수정:** `src/__tests__/medium.integration.spec.tsx` + +--- +## Test Progression Order (테스트 진행 순서) +- **1. 단위 테스트 (Unit Test) TDD 사이클:** 이 스토리는 API 연동 및 여러 컴포넌트/훅의 상호작용이 핵심이므로, 통합 테스트에 집중합니다. +- **2. 통합 테스트 (Integration Test) TDD 사이클:** '예' 버튼 클릭 시 API 호출 및 UI 변경이 올바르게 일어나는지 검증하는 통합 테스트 TDD 사이클(RED-GREEN-REFACTOR)을 진행합니다. + +## UI Flow for Integration Test (통합 테스트용 UI 플로우) +- MSW를 사용하여 `seriesId`를 가진 반복 이벤트를 반환하도록 설정한다. +- 해당 이벤트의 '수정' 버튼을 클릭한다. +- 나타난 다이얼로그에서 '예' 버튼을 클릭한다. +- `PUT /api/events/:id/detach` API가 호출되었는지 검증한다. (MSW 핸들러를 통해) +- API가 `seriesId: null`로 업데이트된 이벤트를 반환하도록 설정한다. +- 테스트 화면에서 해당 이벤트의 반복 아이콘(`ReplayIcon`)이 사라졌는지(`not.toBeInTheDocument`) 검증한다. + +## Integration Test Requirement (통합 테스트 필요) +- 예, UI 상호작용, API 호출, 여러 컴포넌트/훅의 연동을 포함하므로 통합 테스트가 필요합니다. + +--- +## Commit Messages (통합 테스트 - 단일 반복 수정) +- **[Tidy]**: `N/A` +- **[RED]**: `test(eventForm): Add failing integration test for detaching a single recurring event` +- **[GREEN]**: `feat(eventForm): Implement logic to detach a single recurring event` +- **[REFACTOR]**: `refactor(eventForm): Improve clarity of event detachment logic` From b9022acb38e5321c59040658a6de3bc33804ecba Mon Sep 17 00:00:00 2001 From: Jihoon-Yoon96 <03470@naver.com> Date: Fri, 31 Oct 2025 14:15:44 +0900 Subject: [PATCH 084/106] =?UTF-8?q?COMMIT=20-=20(RED)=20[Story=209:=20?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=20=EC=9D=BC=EC=A0=95=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?('=EC=98=88'=20=EC=84=A0=ED=83=9D)]=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index e5c43bf4..81f5dcab 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -662,4 +662,58 @@ describe('반복 일정 수정 확인 다이얼로그', () => { expect(within(dialog).getByRole('button', { name: '예' })).toBeInTheDocument(); expect(within(dialog).getByRole('button', { name: '아니오' })).toBeInTheDocument(); }); + + it('반복 일정 수정 다이얼로그에서 \'예\' 버튼 클릭 시, 해당 이벤트가 단일 일정으로 분리되어야 한다', async () => { + // GIVEN: seriesId를 가진 반복 이벤트가 존재 + const mockEvents = [ + { + id: '1', + title: '반복되지 않는 일정', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + seriesId: null, + }, + { + id: '2', + title: '반복되는 일정', + date: '2025-10-16', + startTime: '11:00', + endTime: '12:00', + description: '', + location: '', + category: '개인', + repeat: { type: 'daily', interval: 1 }, + notificationTime: 10, + seriesId: 'series-1', + }, + ]; + setupMockHandlerUpdating(mockEvents); + + const { user } = setup(); + + // WHEN: 반복 일정의 수정 버튼 클릭 후 다이얼로그에서 '예' 버튼 클릭 + const recurringEditButton = await screen.findByRole('button', { name: 'Edit event 반복되는 일정' }); + await user.click(recurringEditButton); + act(() => { + vi.runOnlyPendingTimers(); + }); + + const dialog = await screen.findByRole('dialog', { name: '일정 수정 확인' }); + const yesButton = within(dialog).getByRole('button', { name: '예' }); + await user.click(yesButton); + act(() => { + vi.runOnlyPendingTimers(); + }); + + // THEN: `PUT /api/events/:id/detach` API가 호출되었고, UI에서 반복 아이콘이 사라졌는지 확인 + const updatedEventItem = await screen.findByText('반복되는 일정'); + const updatedEventContainer = updatedEventItem.closest('div'); + expect(within(updatedEventContainer!).queryByTestId('ReplayIcon')).not.toBeInTheDocument(); + }); }); From a8cbbeb4fdda6e901e25b32a5d8e41c4444ee0d1 Mon Sep 17 00:00:00 2001 From: Jihoon-Yoon96 <03470@naver.com> Date: Fri, 31 Oct 2025 14:19:45 +0900 Subject: [PATCH 085/106] =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC,=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=83=80=EC=9E=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__mocks__/handlersUtils.ts | 13 +++++++++++-- src/types.ts | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index f927afb9..10015fa9 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -20,8 +20,8 @@ export const setupMockHandlerCreation = (initEvents = [] as Event[]) => { ); }; -export const setupMockHandlerUpdating = () => { - const mockEvents: Event[] = [ +export const setupMockHandlerUpdating = (initialEvents?: Event[]) => { + const mockEvents: Event[] = initialEvents || [ { id: '1', title: '기존 회의', @@ -33,6 +33,7 @@ export const setupMockHandlerUpdating = () => { category: '업무', repeat: { type: 'none', interval: 0 }, notificationTime: 10, + seriesId: null, }, { id: '2', @@ -45,6 +46,7 @@ export const setupMockHandlerUpdating = () => { category: '업무', repeat: { type: 'none', interval: 0 }, notificationTime: 10, + seriesId: null, }, ]; @@ -59,6 +61,13 @@ export const setupMockHandlerUpdating = () => { mockEvents[index] = { ...mockEvents[index], ...updatedEvent }; return HttpResponse.json(mockEvents[index]); + }), + http.put('/api/events/:id/detach', ({ params }) => { + const { id } = params; + const index = mockEvents.findIndex((event) => event.id === id); + mockEvents[index].seriesId = null; + mockEvents[index].repeat = { type: 'none', interval: 0 }; + return HttpResponse.json(mockEvents[index]); }) ); }; diff --git a/src/types.ts b/src/types.ts index 78c58be3..778651d9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,4 +24,5 @@ export interface EventForm { export interface Event extends EventForm { id: string; + seriesId: string | null; // 반복 일정 그룹 ID (단일 일정일 경우 null) } From ce93167053340a9a552f330667573a2418e1b8b8 Mon Sep 17 00:00:00 2001 From: Jihoon-Yoon96 <03470@naver.com> Date: Fri, 31 Oct 2025 14:19:57 +0900 Subject: [PATCH 086/106] =?UTF-8?q?COMMIT=20-=20(GREEN)=20[Story=209:=20?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=20=EC=9D=BC=EC=A0=95=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?('=EC=98=88'=20=EC=84=A0=ED=83=9D)]=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 5 +++-- src/__tests__/medium.integration.spec.tsx | 3 ++- src/hooks/useEventOperations.ts | 16 +++++++++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 600c2ac5..3f2af13e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,7 +103,7 @@ function App() { editEvent, } = useEventForm(); - const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () => + const { events, saveEvent, deleteEvent, detachEventFromSeries } = useEventOperations(Boolean(editingEvent), () => setEditingEvent(null) ); @@ -664,9 +664,10 @@ function App() { - - + + diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index 10015fa9..16f6c1e8 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -110,12 +110,12 @@ export const setupMockGetEvents = (mockEvents: Event[]) => { ); }; -export const setupMockPostRequestHandler = (onPost: (body: any) => void) => { +export const setupMockPostRequestHandler = (onPost: (body: Event) => void) => { server.use( http.post('/api/events', async ({ request }) => { - const requestBody = await request.json(); - onPost(requestBody); - return HttpResponse.json(requestBody, { status: 201 }); + const _requestBody = await request.json(); + onPost(_requestBody); + return HttpResponse.json(_requestBody, { status: 201 }); }), http.get('/api/events', () => { return HttpResponse.json({ events: [] }); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 54b579b6..9d4609a5 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,6 +1,6 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act, waitFor } from '@testing-library/react'; +import { render, screen, within, act } from '@testing-library/react'; import { UserEvent, userEvent } from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; @@ -359,7 +359,7 @@ describe('반복 일정 유형 선택 UI 통합 테스트', () => { // render(); // }); - it('일정 생성/수정 폼에서 \'반복\' 체크박스 선택 시 반복 주기 입력 영역이 노출되어야 한다', async () => { + it("일정 생성/수정 폼에서 '반복' 체크박스 선택 시 반복 주기 입력 영역이 노출되어야 한다", async () => { setupMockHandlerCreation([]); const { user } = setup(); @@ -378,9 +378,19 @@ describe('반복 일정 유형 선택 UI 통합 테스트', () => { const createRecurringEvent = async ( user: UserEvent, - options: { title: string; date: string; startTime: string; endTime: string; repeatType: 'daily' | 'weekly' | 'monthly' | 'yearly'; daysOfWeek?: number[]; dayOfMonth?: number; monthOfYear?: number } + options: { + title: string; + date: string; + startTime: string; + endTime: string; + repeatType: 'daily' | 'weekly' | 'monthly' | 'yearly'; + daysOfWeek?: number[]; + dayOfMonth?: number; + monthOfYear?: number; + } ) => { - const { title, date, startTime, endTime, repeatType, daysOfWeek, dayOfMonth, monthOfYear } = options; + const { title, date, startTime, endTime, repeatType, daysOfWeek, dayOfMonth, monthOfYear } = + options; await user.type(screen.getByLabelText('제목'), title); await user.type(screen.getByLabelText('날짜'), date); @@ -438,7 +448,7 @@ describe('반복 일정 시각적 표시 (사용자 시나리오)', () => { describe('반복 종료일 저장', () => { it('사용자가 입력한 반복 종료일이 API 요청에 올바르게 포함되어야 한다.', async () => { // GIVEN - let requestBody: any; + let requestBody: Event; setupMockPostRequestHandler((body) => { requestBody = body; }); @@ -548,11 +558,11 @@ describe('반복 일정 확장 표시 (통합)', () => { }); }); -// Story 7 - 반복 종료일 저장 -describe('반복 종료일 저장', () => { +// Story 7 - 반복 종료일 저장 (비반복 시나리오) +describe('반복 종료일 저장 (비반복 시나리오)', () => { it('사용자가 입력한 반복 종료일이 API 요청에 올바르게 포함되어야 한다.', async () => { // GIVEN - let requestBody: any; + let requestBody: unknown; setupMockPostRequestHandler((body) => { requestBody = body; }); @@ -569,12 +579,12 @@ describe('반복 종료일 저장', () => { await user.click(screen.getByTestId('event-submit-button')); // THEN - expect(requestBody.repeat.endDate).toBe(endDateToSubmit); + expect((requestBody as Event).repeat.endDate).toBe(endDateToSubmit); }); it('반복 일정이 아닐 경우, repeat.endDate는 API 요청에 포함되지 않아야 한다', async () => { // GIVEN - let requestBody: any; + let requestBody: unknown; setupMockPostRequestHandler((body) => { requestBody = body; }); @@ -586,7 +596,7 @@ describe('반복 종료일 저장', () => { await user.type(screen.getByLabelText('날짜'), '2025-10-01'); await user.type(screen.getByLabelText('시작 시간'), '10:00'); await user.type(screen.getByLabelText('종료 시간'), '11:00'); - + // 사용자가 반복을 설정했다가 다시 취소하는 흐름 const repeatCheckbox = screen.getByLabelText('반복 일정'); await user.click(repeatCheckbox); // 1. 반복 체크 @@ -596,10 +606,9 @@ describe('반복 종료일 저장', () => { await user.click(screen.getByTestId('event-submit-button')); // THEN - expect(requestBody.repeat.endDate).toBeUndefined(); + expect((requestBody as Event).repeat.endDate).toBeUndefined(); }); }); - // RED 단계: Story 8 - 반복 일정 수정 확인 다이얼로그 표시 describe('반복 일정 수정 확인 다이얼로그', () => { beforeEach(() => { @@ -635,7 +644,9 @@ describe('반복 일정 수정 확인 다이얼로그', () => { const { user } = setup(); // 일반 일정의 수정 버튼 클릭 - const nonRecurringEditButton = await screen.findByRole('button', { name: 'Edit event 반복되지 않는 일정' }); + const nonRecurringEditButton = await screen.findByRole('button', { + name: 'Edit event 반복되지 않는 일정', + }); await user.click(nonRecurringEditButton); // 다이얼로그가 나타나지 않음을 확인 @@ -648,7 +659,9 @@ describe('반복 일정 수정 확인 다이얼로그', () => { const { user } = setup(); // 반복 일정의 수정 버튼 클릭 - const recurringEditButton = await screen.findByRole('button', { name: 'Edit event 반복되는 일정' }); + const recurringEditButton = await screen.findByRole('button', { + name: 'Edit event 반복되는 일정', + }); await user.click(recurringEditButton); // Fake Timers 환경에서 user-event로 인한 상태 업데이트를 수동으로 실행 @@ -663,7 +676,7 @@ describe('반복 일정 수정 확인 다이얼로그', () => { expect(within(dialog).getByRole('button', { name: '아니오' })).toBeInTheDocument(); }); - it('반복 일정 수정 다이얼로그에서 \'예\' 버튼 클릭 시, 해당 이벤트가 단일 일정으로 분리되어야 한다', async () => { + it("반복 일정 수정 다이얼로그에서 '예' 버튼 클릭 시, 해당 이벤트가 단일 일정으로 분리되어야 한다", async () => { // GIVEN: seriesId를 가진 반복 이벤트가 존재 const mockEvents = [ { @@ -698,7 +711,9 @@ describe('반복 일정 수정 확인 다이얼로그', () => { const { user } = setup(); // WHEN: 반복 일정의 수정 버튼 클릭 후 다이얼로그에서 '예' 버튼 클릭 - const recurringEditButton = await screen.findByRole('button', { name: 'Edit event 반복되는 일정' }); + const recurringEditButton = await screen.findByRole('button', { + name: 'Edit event 반복되는 일정', + }); await user.click(recurringEditButton); act(() => { vi.runOnlyPendingTimers(); diff --git a/src/__tests__/utils/repeatUtils.spec.ts b/src/__tests__/utils/repeatUtils.spec.ts index 54e285d2..4da71c39 100644 --- a/src/__tests__/utils/repeatUtils.spec.ts +++ b/src/__tests__/utils/repeatUtils.spec.ts @@ -1,6 +1,11 @@ // src/__tests__/utils/repeatUtils.spec.ts -import { calculateDailyDates, calculateWeeklyDates, calculateMonthlyDates, calculateYearlyDates, expandRecurringEvents } from '../../utils/repeatUtils'; -import { afterEach } from 'vitest'; +import { + calculateDailyDates, + calculateWeeklyDates, + calculateMonthlyDates, + calculateYearlyDates, + expandRecurringEvents, +} from '../../utils/repeatUtils'; describe('calculateDailyDates', () => { it('간격이 1일 때 종료일까지 매일 반복되는 날짜를 올바르게 생성해야 한다', () => { @@ -120,7 +125,9 @@ describe('calculateYearlyDates', () => { const month = 0; // January (0-indexed) const dayOfMonth = 15; const expectedDates = ['2025-01-15', '2026-01-15', '2027-01-15']; - expect(calculateYearlyDates(startDate, interval, month, dayOfMonth, endDate)).toEqual(expectedDates); + expect(calculateYearlyDates(startDate, interval, month, dayOfMonth, endDate)).toEqual( + expectedDates + ); }); }); @@ -205,7 +212,13 @@ describe('expandRecurringEvents', () => { date: '2024-02-29', // 윤년 startTime: '09:00', endTime: '18:00', - repeat: { type: 'yearly', interval: 1, monthOfYear: 1, dayOfMonth: 29, endDate: '2028-02-29' }, + repeat: { + type: 'yearly', + interval: 1, + monthOfYear: 1, + dayOfMonth: 29, + endDate: '2028-02-29', + }, }; const events = [yearlyEvent]; const rangeStart = new Date('2024-01-01'); @@ -228,7 +241,13 @@ describe('expandRecurringEvents', () => { date: '2024-02-29', // 윤년 시작일 startTime: '10:00', endTime: '11:00', - repeat: { type: 'yearly', interval: 1, monthOfYear: 1, dayOfMonth: 29, endDate: '2028-02-29' }, + repeat: { + type: 'yearly', + interval: 1, + monthOfYear: 1, + dayOfMonth: 29, + endDate: '2028-02-29', + }, }; const events = [leapYearEvent]; const rangeStart = new Date('2024-01-01'); diff --git a/src/components/RepeatOptions.tsx b/src/components/RepeatOptions.tsx index 054b1ccb..353e9cbc 100644 --- a/src/components/RepeatOptions.tsx +++ b/src/components/RepeatOptions.tsx @@ -2,17 +2,17 @@ import { RepeatType } from '../types'; interface RepeatOptionsProps { repeatType: RepeatType; - setRepeatType: (type: RepeatType) => void; + setRepeatType: (_type: RepeatType) => void; repeatInterval: number; - setRepeatInterval: (interval: number) => void; + setRepeatInterval: (_interval: number) => void; repeatEndDate: string; - setRepeatEndDate: (date: string) => void; + setRepeatEndDate: (_date: string) => void; daysOfWeek: number[]; - setDaysOfWeek: (days: number[]) => void; + setDaysOfWeek: (_days: number[]) => void; dayOfMonth: number; - setDayOfMonth: (day: number) => void; + setDayOfMonth: (_day: number) => void; monthOfYear: number; - setMonthOfYear: (month: number) => void; + setMonthOfYear: (_month: number) => void; } const weekDays = ['일', '월', '화', '수', '목', '금', '토']; @@ -42,7 +42,9 @@ export const RepeatOptions = ({ return (
- +