From 0f8cc1989b84695b62eff0894fccd0ac46d75e2d Mon Sep 17 00:00:00 2001 From: xxziiko Date: Sat, 23 Aug 2025 17:54:04 +0900 Subject: [PATCH 01/34] =?UTF-8?q?=EA=B3=BC=EC=A0=9C=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 916737ce4daf3165b034268d844a25c97cd79325 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Wed, 27 Aug 2025 15:12:05 +0900 Subject: [PATCH 02/34] =?UTF-8?q?fix:=20server=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index ec83fbad..8b191b13 100644 --- a/server.js +++ b/server.js @@ -38,7 +38,7 @@ app.post('/api/events', async (req, res) => { app.put('/api/events/:id', async (req, res) => { const events = await getEvents(); - const { id } = req.params; + const id = req.params.id; const eventIndex = events.events.findIndex((event) => event.id === id); if (eventIndex > -1) { const newEvents = [...events.events]; @@ -59,7 +59,7 @@ app.put('/api/events/:id', async (req, res) => { app.delete('/api/events/:id', async (req, res) => { const events = await getEvents(); - const { id } = req.params; + const id = req.params.id; fs.writeFileSync( `${__dirname}/src/__mocks__/response/realEvents.json`, @@ -71,6 +71,72 @@ app.delete('/api/events/:id', async (req, res) => { res.status(204).send(); }); +app.post('/api/events-list', async (req, res) => { + const events = await getEvents(); + const repeatId = randomUUID(); + const newEvents = req.body.events.map((event) => { + const isRepeatEvent = event.repeat.type !== 'none'; + return { + id: randomUUID(), + ...event, + repeat: { + ...event.repeat, + id: isRepeatEvent ? repeatId : undefined, + }, + }; + }); + + fs.writeFileSync( + `${__dirname}/src/__mocks__/response/realEvents.json`, + JSON.stringify({ + events: [...events.events, ...newEvents], + }) + ); + + res.status(201).json(newEvents); +}); + +app.put('/api/events-list', async (req, res) => { + const events = await getEvents(); + let isUpdated = false; + + const newEvents = [...events.events]; + req.body.events.forEach((event) => { + const eventIndex = events.events.findIndex((target) => target.id === event.id); + if (eventIndex > -1) { + isUpdated = true; + newEvents[eventIndex] = { ...events.events[eventIndex], ...event }; + } + }); + + if (isUpdated) { + fs.writeFileSync( + `${__dirname}/src/__mocks__/response/realEvents.json`, + JSON.stringify({ + events: newEvents, + }) + ); + + res.json(events.events); + } else { + res.status(404).send('Event not found'); + } +}); + +app.delete('/api/events-list', async (req, res) => { + const events = await getEvents(); + const newEvents = events.events.filter((event) => !req.body.eventIds.includes(event.id)); // ? ids를 전달하면 해당 아이디를 기준으로 events에서 제거 + + fs.writeFileSync( + `${__dirname}/src/__mocks__/response/realEvents.json`, + JSON.stringify({ + events: newEvents, + }) + ); + + res.status(204).send(); +}); + app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); }); From 1bf65c9a8196402aa3bae2f6ff0b3a243b5efeda Mon Sep 17 00:00:00 2001 From: xxziiko Date: Wed, 27 Aug 2025 17:11:19 +0900 Subject: [PATCH 03/34] =?UTF-8?q?test:=20generateRepeatDates=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/repeatEventGeneration.spec.ts | 212 ++++++++++++++++++ src/utils/repeatEvent.ts | 13 ++ 2 files changed, 225 insertions(+) create mode 100644 src/__tests__/unit/repeatEventGeneration.spec.ts create mode 100644 src/utils/repeatEvent.ts diff --git a/src/__tests__/unit/repeatEventGeneration.spec.ts b/src/__tests__/unit/repeatEventGeneration.spec.ts new file mode 100644 index 00000000..c42648d6 --- /dev/null +++ b/src/__tests__/unit/repeatEventGeneration.spec.ts @@ -0,0 +1,212 @@ +import { generateRepeatDates } from '../../utils/repeatEvent'; + +describe('반복 일정 생성 테스트', () => { + describe('매월 반복', () => { + it('31일에 매월 반복을 선택하면 2월을 제외한 매월 31일에 일정을 생성한다', () => { + // Given: 31일에 매월 반복 일정을 생성하는 경우 + const event = { + date: '2025-01-31', + repeatType: 'monthly' as const, + interval: 1, + endDate: '2025-10-30', + }; + + // When: 반복 일정을 생성할 때 + const repeatDates = generateRepeatDates(event); + + // Then: 2월을 제외한 매월 31일에만 일정이 생성되어야 한다 + expect(repeatDates).toEqual([ + '2025-01-31', + '2025-03-31', // 2월은 28일이므로 제외 + '2025-05-31', + '2025-07-31', + '2025-08-31', + '2025-10-31', + ]); + }); + + it('30일에 매월 반복을 선택하면 2월은 28일(또는 29일)에 일정을 생성한다', () => { + // Given: 30일에 매월 반복 일정을 생성하는 경우 + const event = { + date: '2025-01-30', + repeatType: 'monthly' as const, + interval: 1, + endDate: '2025-06-30', + }; + + // When: 반복 일정을 생성할 때 + const repeatDates = generateRepeatDates(event); + + // Then: 2월은 28일까지만, 나머지는 30일에 일정이 생성되어야 한다 + expect(repeatDates).toEqual([ + '2025-01-30', + '2025-02-28', // 2월은 28일까지만 + '2025-03-30', + '2025-04-30', + '2025-05-30', + '2025-06-30', + ]); + }); + }); + + describe('매년 반복', () => { + it('윤년 2월 29일에 매년 반복을 선택하면 매년 2월 29일에만 일정을 생성한다', () => { + // Given: 윤년 2월 29일에 매년 반복 일정을 생성하는 경우 + const event = { + date: '2024-02-29', // 윤년 + repeatType: 'yearly' as const, + interval: 1, + endDate: '2028-02-29', + }; + + // When: 반복 일정을 생성할 때 + const repeatDates = generateRepeatDates(event); + + // Then: 윤년의 2월 29일에만 일정이 생성되어야 한다 + expect(repeatDates).toEqual([ + '2024-02-29', // 2024년 (윤년) + '2028-02-29', // 2028년 (윤년) + // 2025, 2026, 2027은 윤년이 아니므로 제외 + ]); + }); + }); + + describe('기본 반복 유형', () => { + it('매일 반복을 선택하면 시작일부터 종료일까지 매일 일정을 생성한다', () => { + const event = { + date: '2025-01-01', + repeatType: 'daily' as const, + interval: 1, + endDate: '2025-01-05', + }; + + const repeatDates = generateRepeatDates(event); + + expect(repeatDates).toEqual([ + '2025-01-01', + '2025-01-02', + '2025-01-03', + '2025-01-04', + '2025-01-05', + ]); + }); + + it('매주 반복을 선택하면 7일 간격으로 일정을 생성한다', () => { + const event = { + date: '2025-01-01', // 수요일 + repeatType: 'weekly' as const, + interval: 1, + endDate: '2025-01-29', + }; + + const repeatDates = generateRepeatDates(event); + + expect(repeatDates).toEqual([ + '2025-01-01', + '2025-01-08', + '2025-01-15', + '2025-01-22', + '2025-01-29', + ]); + }); + }); +}); + +describe('반복 일정 생성 - 경계값 테스트', () => { + it('반복 종료일이 시작일보다 이전이면 빈 배열을 반환한다', () => { + // Given: 시작일이 2025-01-31이고, 종료일이 2024-12-31인 경우 + const startDate = '2025-01-31'; + const endDate = '2024-12-31'; + const event = { + date: startDate, + repeatType: 'monthly' as const, + interval: 1, + endDate: endDate, + }; + + // When: 반복 일정을 생성할 때 + const repeatDates = generateRepeatDates(event); + + // Then: 빈 배열이 반환되어야 한다 + expect(repeatDates).toEqual([]); + }); +}); + +describe('반복 간격', () => { + it('2주마다 반복을 선택하면 14일 간격으로 일정을 생성한다', () => { + // Given: 2주마다 반복하는 일정을 생성하는 경우 + const event = { + date: '2025-01-01', + repeatType: 'weekly' as const, + interval: 2, // 2주마다 + endDate: '2025-02-26', + }; + + // When: 반복 일정을 생성할 때 + const repeatDates = generateRepeatDates(event); + + // Then: 14일 간격으로 일정이 생성되어야 한다 + expect(repeatDates).toEqual([ + '2025-01-01', + '2025-01-15', // 14일 후 + '2025-01-29', // 28일 후 + '2025-02-12', // 42일 후 + '2025-02-26', // 56일 후 + ]); + }); + + it('2개월마다 반복을 선택하면 2개월 간격으로 일정을 생성한다', () => { + const event = { + date: '2025-01-31', + repeatType: 'monthly' as const, + interval: 2, // 2개월마다 + endDate: '2025-11-30', + }; + + const repeatDates = generateRepeatDates(event); + + expect(repeatDates).toEqual([ + '2025-01-31', + '2025-03-31', + '2025-05-31', + '2025-07-31', + '2025-09-30', // 9월은 30일까지만 + '2025-11-30', + ]); + }); +}); + +describe('날짜 형식 및 유효성', () => { + it('잘못된 날짜 형식에 대해 에러를 발생시킨다', () => { + const event = { + date: 'invalid-date', + repeatType: 'daily' as const, + interval: 1, + endDate: '2025-01-05', + }; + + expect(() => generateRepeatDates(event)).toThrow('Invalid date format'); + }); +}); + +// 추가 필요한 테스트 +describe('윤년 처리', () => { + it('윤년 2월 29일에 매년 반복을 선택하면 매년 2월 29일에만 일정을 생성한다', () => { + // Given: 윤년 2월 29일에 매년 반복 일정을 생성하는 경우 + const event = { + date: '2024-02-29', // 윤년 + repeatType: 'yearly' as const, + interval: 1, + endDate: '2028-02-29', + }; + + // When: 반복 일정을 생성할 때 + const repeatDates = generateRepeatDates(event); + + // Then: 윤년의 2월 29일에만 일정이 생성되어야 한다 + expect(repeatDates).toEqual([ + '2024-02-29', // 2024년 (윤년) + '2028-02-29', // 2028년 (윤년) + ]); + }); +}); diff --git a/src/utils/repeatEvent.ts b/src/utils/repeatEvent.ts new file mode 100644 index 00000000..425f431c --- /dev/null +++ b/src/utils/repeatEvent.ts @@ -0,0 +1,13 @@ +import { RepeatInfo } from '../types'; + +interface RepeatEventInput { + date: string; + repeatType: RepeatInfo['type']; + interval: number; + endDate: string; +} + +export const generateRepeatDates = (event: RepeatEventInput): string[] => { + // TODO: 반복 일정 생성 로직 구현 + throw new Error('Not implemented yet'); +}; From 0f784363269e787637ea4b27ff6ec0c4bea24b84 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Wed, 27 Aug 2025 17:52:13 +0900 Subject: [PATCH 04/34] =?UTF-8?q?chore:=20repeatEvent=20->=20repeatEventUt?= =?UTF-8?q?ils=ED=8C=8C=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/unit/repeatEventGeneration.spec.ts | 2 +- src/utils/{repeatEvent.ts => repeatEventUtils.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/utils/{repeatEvent.ts => repeatEventUtils.ts} (100%) diff --git a/src/__tests__/unit/repeatEventGeneration.spec.ts b/src/__tests__/unit/repeatEventGeneration.spec.ts index c42648d6..84f8a253 100644 --- a/src/__tests__/unit/repeatEventGeneration.spec.ts +++ b/src/__tests__/unit/repeatEventGeneration.spec.ts @@ -1,4 +1,4 @@ -import { generateRepeatDates } from '../../utils/repeatEvent'; +import { generateRepeatDates } from '../../utils/repeatEventUtils'; describe('반복 일정 생성 테스트', () => { describe('매월 반복', () => { diff --git a/src/utils/repeatEvent.ts b/src/utils/repeatEventUtils.ts similarity index 100% rename from src/utils/repeatEvent.ts rename to src/utils/repeatEventUtils.ts From d83793158f2ea502f3e5107ce3cd540383d08f08 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Wed, 27 Aug 2025 17:57:13 +0900 Subject: [PATCH 05/34] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9D=BC=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/repeatEventUtils.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/utils/repeatEventUtils.ts b/src/utils/repeatEventUtils.ts index 425f431c..20712573 100644 --- a/src/utils/repeatEventUtils.ts +++ b/src/utils/repeatEventUtils.ts @@ -8,6 +8,30 @@ interface RepeatEventInput { } export const generateRepeatDates = (event: RepeatEventInput): string[] => { - // TODO: 반복 일정 생성 로직 구현 - throw new Error('Not implemented yet'); + const { repeatType } = event; + // 1. 입력 유효성 검사 + // 2. 시작일과 종료일 비교 + // 3. 반복 유형별 날짜 생성 + if (repeatType === 'daily') { + return generateDailyRepeatDates(event); + } + + // 4. 결과 반환 +}; + +const generateDailyRepeatDates = (event: RepeatEventInput): string[] => { + const { date, interval, endDate } = event; + + const repeatDates: string[] = []; + + const start = new Date(date); + const end = new Date(endDate); + + while (start <= end) { + repeatDates.push(start.toISOString().split('T')[0]); + start.setDate(start.getDate() + interval); + } + + return repeatDates; +}; }; From e2a1428cb9bed2fa0cd78ba8b5f1f501fbbba9be Mon Sep 17 00:00:00 2001 From: xxziiko Date: Wed, 27 Aug 2025 18:14:27 +0900 Subject: [PATCH 06/34] =?UTF-8?q?feat:=20=EB=A7=A4=EC=A3=BC=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/repeatEventUtils.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/utils/repeatEventUtils.ts b/src/utils/repeatEventUtils.ts index 20712573..19c4bbf6 100644 --- a/src/utils/repeatEventUtils.ts +++ b/src/utils/repeatEventUtils.ts @@ -16,22 +16,32 @@ export const generateRepeatDates = (event: RepeatEventInput): string[] => { return generateDailyRepeatDates(event); } + if (repeatType === 'weekly') { + return generateWeeklyRepeatDates(event); + } + // 4. 결과 반환 }; -const generateDailyRepeatDates = (event: RepeatEventInput): string[] => { - const { date, interval, endDate } = event; - +const generateRepeatDatesForType = (event: RepeatEventInput, incrementDays: number): string[] => { + const { date, endDate } = event; const repeatDates: string[] = []; - const start = new Date(date); const end = new Date(endDate); while (start <= end) { repeatDates.push(start.toISOString().split('T')[0]); - start.setDate(start.getDate() + interval); + start.setDate(start.getDate() + incrementDays); } return repeatDates; }; + +const generateDailyRepeatDates = (event: RepeatEventInput): string[] => { + return generateRepeatDatesForType(event, event.interval); +}; + +const generateWeeklyRepeatDates = (event: RepeatEventInput): string[] => { + return generateRepeatDatesForType(event, event.interval * 7); +}; }; From 6e97e49ff44875ad7801076758b9f52d85a00f49 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Thu, 28 Aug 2025 02:59:01 +0900 Subject: [PATCH 07/34] =?UTF-8?q?test:=20=EC=A4=91=EB=B3=B5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/repeatEventGeneration.spec.ts | 96 +------------------ 1 file changed, 1 insertion(+), 95 deletions(-) diff --git a/src/__tests__/unit/repeatEventGeneration.spec.ts b/src/__tests__/unit/repeatEventGeneration.spec.ts index 84f8a253..5d176c97 100644 --- a/src/__tests__/unit/repeatEventGeneration.spec.ts +++ b/src/__tests__/unit/repeatEventGeneration.spec.ts @@ -21,30 +21,6 @@ describe('반복 일정 생성 테스트', () => { '2025-05-31', '2025-07-31', '2025-08-31', - '2025-10-31', - ]); - }); - - it('30일에 매월 반복을 선택하면 2월은 28일(또는 29일)에 일정을 생성한다', () => { - // Given: 30일에 매월 반복 일정을 생성하는 경우 - const event = { - date: '2025-01-30', - repeatType: 'monthly' as const, - interval: 1, - endDate: '2025-06-30', - }; - - // When: 반복 일정을 생성할 때 - const repeatDates = generateRepeatDates(event); - - // Then: 2월은 28일까지만, 나머지는 30일에 일정이 생성되어야 한다 - expect(repeatDates).toEqual([ - '2025-01-30', - '2025-02-28', // 2월은 28일까지만 - '2025-03-30', - '2025-04-30', - '2025-05-30', - '2025-06-30', ]); }); }); @@ -63,11 +39,7 @@ describe('반복 일정 생성 테스트', () => { const repeatDates = generateRepeatDates(event); // Then: 윤년의 2월 29일에만 일정이 생성되어야 한다 - expect(repeatDates).toEqual([ - '2024-02-29', // 2024년 (윤년) - '2028-02-29', // 2028년 (윤년) - // 2025, 2026, 2027은 윤년이 아니므로 제외 - ]); + expect(repeatDates).toEqual(['2024-02-29', '2028-02-29']); }); }); @@ -132,50 +104,6 @@ describe('반복 일정 생성 - 경계값 테스트', () => { }); }); -describe('반복 간격', () => { - it('2주마다 반복을 선택하면 14일 간격으로 일정을 생성한다', () => { - // Given: 2주마다 반복하는 일정을 생성하는 경우 - const event = { - date: '2025-01-01', - repeatType: 'weekly' as const, - interval: 2, // 2주마다 - endDate: '2025-02-26', - }; - - // When: 반복 일정을 생성할 때 - const repeatDates = generateRepeatDates(event); - - // Then: 14일 간격으로 일정이 생성되어야 한다 - expect(repeatDates).toEqual([ - '2025-01-01', - '2025-01-15', // 14일 후 - '2025-01-29', // 28일 후 - '2025-02-12', // 42일 후 - '2025-02-26', // 56일 후 - ]); - }); - - it('2개월마다 반복을 선택하면 2개월 간격으로 일정을 생성한다', () => { - const event = { - date: '2025-01-31', - repeatType: 'monthly' as const, - interval: 2, // 2개월마다 - endDate: '2025-11-30', - }; - - const repeatDates = generateRepeatDates(event); - - expect(repeatDates).toEqual([ - '2025-01-31', - '2025-03-31', - '2025-05-31', - '2025-07-31', - '2025-09-30', // 9월은 30일까지만 - '2025-11-30', - ]); - }); -}); - describe('날짜 형식 및 유효성', () => { it('잘못된 날짜 형식에 대해 에러를 발생시킨다', () => { const event = { @@ -188,25 +116,3 @@ describe('날짜 형식 및 유효성', () => { expect(() => generateRepeatDates(event)).toThrow('Invalid date format'); }); }); - -// 추가 필요한 테스트 -describe('윤년 처리', () => { - it('윤년 2월 29일에 매년 반복을 선택하면 매년 2월 29일에만 일정을 생성한다', () => { - // Given: 윤년 2월 29일에 매년 반복 일정을 생성하는 경우 - const event = { - date: '2024-02-29', // 윤년 - repeatType: 'yearly' as const, - interval: 1, - endDate: '2028-02-29', - }; - - // When: 반복 일정을 생성할 때 - const repeatDates = generateRepeatDates(event); - - // Then: 윤년의 2월 29일에만 일정이 생성되어야 한다 - expect(repeatDates).toEqual([ - '2024-02-29', // 2024년 (윤년) - '2028-02-29', // 2028년 (윤년) - ]); - }); -}); From 4df8acc13d9777905a7d5f02278cdf43e4510022 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Thu, 28 Aug 2025 02:59:25 +0900 Subject: [PATCH 08/34] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9B=94,=20=EB=A7=A4?= =?UTF-8?q?=EB=85=84=20=EB=B0=98=EB=B3=B5=EC=9D=BC=EC=A0=95=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/repeatEventUtils.ts | 159 ++++++++++++++++++++++++++++++---- 1 file changed, 142 insertions(+), 17 deletions(-) diff --git a/src/utils/repeatEventUtils.ts b/src/utils/repeatEventUtils.ts index 19c4bbf6..67c080e8 100644 --- a/src/utils/repeatEventUtils.ts +++ b/src/utils/repeatEventUtils.ts @@ -9,9 +9,17 @@ interface RepeatEventInput { export const generateRepeatDates = (event: RepeatEventInput): string[] => { const { repeatType } = event; - // 1. 입력 유효성 검사 - // 2. 시작일과 종료일 비교 - // 3. 반복 유형별 날짜 생성 + + // 입력 유효성 검사 + if (!isValidDate(event.date) || !isValidDate(event.endDate)) { + throw new Error('Invalid date format'); + } + + // 시작일과 종료일 비교 + if (new Date(event.date) > new Date(event.endDate)) { + return []; + } + if (repeatType === 'daily') { return generateDailyRepeatDates(event); } @@ -20,28 +28,145 @@ export const generateRepeatDates = (event: RepeatEventInput): string[] => { return generateWeeklyRepeatDates(event); } - // 4. 결과 반환 -}; - -const generateRepeatDatesForType = (event: RepeatEventInput, incrementDays: number): string[] => { - const { date, endDate } = event; - const repeatDates: string[] = []; - const start = new Date(date); - const end = new Date(endDate); + if (repeatType === 'monthly') { + return generateMonthlyRepeatDates(event); + } - while (start <= end) { - repeatDates.push(start.toISOString().split('T')[0]); - start.setDate(start.getDate() + incrementDays); + if (repeatType === 'yearly') { + return generateYearlyRepeatDates(event); } - return repeatDates; + return []; }; const generateDailyRepeatDates = (event: RepeatEventInput): string[] => { - return generateRepeatDatesForType(event, event.interval); + const dates: string[] = []; + let currentDate = new Date(event.date); + const endDate = new Date(event.endDate); + + while (currentDate <= endDate) { + dates.push(formatDate(currentDate)); + currentDate.setDate(currentDate.getDate() + event.interval); + } + + return dates; }; const generateWeeklyRepeatDates = (event: RepeatEventInput): string[] => { - return generateRepeatDatesForType(event, event.interval * 7); + const dates: string[] = []; + let currentDate = new Date(event.date); + const endDate = new Date(event.endDate); + + while (currentDate <= endDate) { + dates.push(formatDate(currentDate)); + currentDate.setDate(currentDate.getDate() + event.interval * 7); + } + + return dates; }; + +const generateMonthlyRepeatDates = (event: RepeatEventInput): string[] => { + const dates: string[] = []; + let currentDate = new Date(event.date); + const endDate = new Date(event.endDate); + const originalDay = currentDate.getDate(); + + while (currentDate <= endDate) { + dates.push(formatDate(currentDate)); + + // 다음 달 계산 + let nextMonth = currentDate.getMonth() + event.interval; + let nextYear = currentDate.getFullYear() + Math.floor(nextMonth / 12); + nextMonth = nextMonth % 12; + + // 원래 날짜가 다음 달에 존재하지 않는 경우 해당 달을 건너뛰기 + const lastDayOfNextMonth = getLastDayOfMonth(nextYear, nextMonth); + + if (originalDay > lastDayOfNextMonth) { + // 해당 달을 건너뛰고 다음 달의 같은 날짜로 설정 + nextMonth = nextMonth + 1; + if (nextMonth >= 12) { + nextMonth = 0; + nextYear += 1; + } + + // 다음 달도 원래 날짜가 없으면 계속 건너뛰기 + while (getLastDayOfMonth(nextYear, nextMonth) < originalDay) { + nextMonth += 1; + if (nextMonth >= 12) { + nextMonth = 0; + nextYear += 1; + } + } + } + + const nextDate = new Date(nextYear, nextMonth, originalDay); + + // endDate를 초과하면 종료 + if (nextDate > endDate) { + break; + } + + currentDate = nextDate; + } + + return dates; +}; + +const generateYearlyRepeatDates = (event: RepeatEventInput): string[] => { + const dates: string[] = []; + let currentDate = new Date(event.date); + const endDate = new Date(event.endDate); + const originalMonth = currentDate.getMonth(); + const originalDay = currentDate.getDate(); + + while (currentDate <= endDate) { + dates.push(formatDate(currentDate)); + + // 다음 해 계산 + let nextYear = currentDate.getFullYear() + event.interval; + + // 원래 날짜가 다음 해에 존재하지 않는 경우 해당 해를 건너뛰기 + const lastDayOfNextMonth = getLastDayOfMonth(nextYear, originalMonth); + + if (originalDay > lastDayOfNextMonth) { + // 해당 해를 건너뛰고 다음 해의 같은 날짜로 설정 + nextYear += 1; + + // 다음 해도 원래 날짜가 없으면 계속 건너뛰기 + while (getLastDayOfMonth(nextYear, originalMonth) < originalDay) { + nextYear += 1; + } + } + + const nextDate = new Date(nextYear, originalMonth, originalDay); + + // endDate를 초과하면 종료 + if (nextDate > endDate) { + break; + } + + currentDate = nextDate; + } + + return dates; +}; + +// 날짜 유효성 검사 +const isValidDate = (dateString: string): boolean => { + const date = new Date(dateString); + return date instanceof Date && !isNaN(date.getTime()); +}; + +// 월의 마지막 날짜 계산 +const getLastDayOfMonth = (year: number, month: number): number => { + return new Date(year, month + 1, 0).getDate(); +}; + +// 날짜를 YYYY-MM-DD 형식으로 변환 +const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; }; From 384e70e62d5cfb7c52ec912df7b3db23b66aa9dd Mon Sep 17 00:00:00 2001 From: xxziiko Date: Thu, 28 Aug 2025 03:06:07 +0900 Subject: [PATCH 09/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=ED=91=9C=EC=8B=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/unit/repeatEventDisplay.spec.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/__tests__/unit/repeatEventDisplay.spec.tsx diff --git a/src/__tests__/unit/repeatEventDisplay.spec.tsx b/src/__tests__/unit/repeatEventDisplay.spec.tsx new file mode 100644 index 00000000..7cba1030 --- /dev/null +++ b/src/__tests__/unit/repeatEventDisplay.spec.tsx @@ -0,0 +1,11 @@ +describe('반복 일정 표시 테스트', () => { + it('반복 일정에는 반복 아이콘이 표시된다', () => { + // TODO: 반복 일정에 반복 아이콘 표시 테스트 구현 + expect(true).toBe(false); // Red 단계: 항상 실패 + }); + + it('반복이 아닌 일정에는 반복 아이콘이 표시되지 않는다', () => { + // TODO: 반복이 아닌 일정에 반복 아이콘 미표시 테스트 구현 + expect(true).toBe(false); // Red 단계: 항상 실패 + }); +}); From 53a9bbfa063fb8437cb06615a1d323489756a33c Mon Sep 17 00:00:00 2001 From: xxziiko Date: Thu, 28 Aug 2025 03:10:20 +0900 Subject: [PATCH 10/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=95=84=EC=9D=B4=EC=BD=98=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/repeatEventDisplay.spec.tsx | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/src/__tests__/unit/repeatEventDisplay.spec.tsx b/src/__tests__/unit/repeatEventDisplay.spec.tsx index 7cba1030..75c56ec3 100644 --- a/src/__tests__/unit/repeatEventDisplay.spec.tsx +++ b/src/__tests__/unit/repeatEventDisplay.spec.tsx @@ -1,11 +1,61 @@ +import { Repeat } from '@mui/icons-material'; +import { Box, Stack, Typography } from '@mui/material'; +import { render, screen } from '@testing-library/react'; + +// 테스트 대상 컴포넌트 +interface EventDisplayProps { + event: { + id: string; + title: string; + repeat: { + type: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; + }; + }; +} + +function EventDisplay({ event }: EventDisplayProps) { + const isRepeating = event.repeat.type !== 'none'; + + return ( + + + {isRepeating && } + {event.title} + + + ); +} + +// 테스트용 일정 데이터 생성 함수 +const createTestEvent = ( + title: string, + repeatType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' +) => ({ + id: `event-${title}`, + title, + repeat: { type: repeatType }, +}); + describe('반복 일정 표시 테스트', () => { it('반복 일정에는 반복 아이콘이 표시된다', () => { - // TODO: 반복 일정에 반복 아이콘 표시 테스트 구현 - expect(true).toBe(false); // Red 단계: 항상 실패 + // Given: 반복 일정이 있는 경우 + const repeatingEvent = createTestEvent('정기 회의', 'weekly'); + + // When: 반복 일정을 렌더링할 때 + render(); + + // Then: 반복 아이콘이 표시되어야 한다 + expect(screen.getByTestId('repeat-icon')).toBeInTheDocument(); }); it('반복이 아닌 일정에는 반복 아이콘이 표시되지 않는다', () => { - // TODO: 반복이 아닌 일정에 반복 아이콘 미표시 테스트 구현 - expect(true).toBe(false); // Red 단계: 항상 실패 + // Given: 반복이 아닌 일정이 있는 경우 + const nonRepeatingEvent = createTestEvent('일회성 미팅', 'none'); + + // When: 반복이 아닌 일정을 렌더링할 때 + render(); + + // Then: 반복 아이콘이 표시되지 않아야 한다 + expect(screen.queryByTestId('repeat-icon')).not.toBeInTheDocument(); }); }); From 3e7078c0db62e1947ceadb6bca2aa05c24992e87 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Thu, 28 Aug 2025 03:26:53 +0900 Subject: [PATCH 11/34] =?UTF-8?q?feat:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=95=84=EC=9D=B4=EC=BD=98=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 15 ++++++-- .../unit/repeatEventDisplay.spec.tsx | 30 ++------------- src/components/RepeatEventIcon.tsx | 38 +++++++++++++++++++ 3 files changed, 52 insertions(+), 31 deletions(-) create mode 100644 src/components/RepeatEventIcon.tsx diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..089eaaee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,12 +30,12 @@ import { import { useSnackbar } from 'notistack'; import { useState } from 'react'; +import { RepeatEventIcon } from './components/RepeatEventIcon'; import { useCalendarView } from './hooks/useCalendarView.ts'; 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 { formatDate, @@ -77,11 +77,8 @@ function App() { isRepeating, setIsRepeating, repeatType, - // setRepeatType, repeatInterval, - // setRepeatInterval, repeatEndDate, - // setRepeatEndDate, notificationTime, setNotificationTime, startTimeError, @@ -201,6 +198,11 @@ function App() { > {isNotified && } + {isNotified && } + - - {isRepeating && } - {event.title} - - - ); -} +import { RepeatEventIcon } from '../../components/RepeatEventIcon'; // 테스트용 일정 데이터 생성 함수 const createTestEvent = ( @@ -42,7 +18,7 @@ describe('반복 일정 표시 테스트', () => { const repeatingEvent = createTestEvent('정기 회의', 'weekly'); // When: 반복 일정을 렌더링할 때 - render(); + render(); // Then: 반복 아이콘이 표시되어야 한다 expect(screen.getByTestId('repeat-icon')).toBeInTheDocument(); @@ -53,7 +29,7 @@ describe('반복 일정 표시 테스트', () => { const nonRepeatingEvent = createTestEvent('일회성 미팅', 'none'); // When: 반복이 아닌 일정을 렌더링할 때 - render(); + render(); // Then: 반복 아이콘이 표시되지 않아야 한다 expect(screen.queryByTestId('repeat-icon')).not.toBeInTheDocument(); diff --git a/src/components/RepeatEventIcon.tsx b/src/components/RepeatEventIcon.tsx new file mode 100644 index 00000000..56644d09 --- /dev/null +++ b/src/components/RepeatEventIcon.tsx @@ -0,0 +1,38 @@ +import { Repeat } from '@mui/icons-material'; +import { Box, Tooltip } from '@mui/material'; + +interface RepeatEventIconProps { + repeatType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; + interval?: number; + endDate?: string; +} + +export function RepeatEventIcon({ repeatType, interval = 1, endDate }: RepeatEventIconProps) { + if (repeatType === 'none') { + return null; + } + + const getRepeatText = () => { + const typeText = { + daily: '일', + weekly: '주', + monthly: '월', + yearly: '년', + }[repeatType]; + + return `${interval}${typeText}마다${endDate ? ` (종료: ${endDate})` : ''}`; + }; + + return ( + + + + + + ); +} From 79e0b7dbee39a4484291f128d18756e9404b2785 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Thu, 28 Aug 2025 03:49:17 +0900 Subject: [PATCH 12/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/unit/repeatEndCondition.spec.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/__tests__/unit/repeatEndCondition.spec.ts diff --git a/src/__tests__/unit/repeatEndCondition.spec.ts b/src/__tests__/unit/repeatEndCondition.spec.ts new file mode 100644 index 00000000..8c912645 --- /dev/null +++ b/src/__tests__/unit/repeatEndCondition.spec.ts @@ -0,0 +1,69 @@ +import { generateRepeatDates } from '../../utils/repeatEventUtils'; + +describe('반복 종료 테스트', () => { + it('반복 종료 조건을 지정할 수 있다', () => { + // Given: 반복 종료 조건을 지정하는 경우 + const event = { + date: '2025-01-01', + repeatType: 'weekly' as const, + interval: 1, + endDate: '2025-02-26', + }; + + // When: 반복 종료 조건을 지정할 때 + const repeatDates = generateRepeatDates(event); + + // Then: 반복 종료 조건이 적용되어야 한다 + expect(repeatDates).toEqual([ + '2025-01-01', + '2025-01-08', + '2025-01-15', + '2025-01-22', + '2025-01-29', + '2025-02-05', + '2025-02-12', + '2025-02-19', + '2025-02-26', + ]); + }); + + it('특정 날짜까지 반복 일정을 생성한다', () => { + // Given: 특정 날짜까지 반복 일정을 생성하는 경우 + const event = { + date: '2025-01-31', + repeatType: 'monthly' as const, + interval: 1, + endDate: '2025-08-31', + }; + + // When: 특정 날짜까지 반복 일정을 생성할 때 + const repeatDates = generateRepeatDates(event); + + // Then: 특정 날짜까지 반복 일정이 생성되어야 한다 + expect(repeatDates).toEqual([ + '2025-01-31', + '2025-03-31', + '2025-05-31', + '2025-07-31', + '2025-08-31', + ]); + }); + + it('2025-10-30까지 최대 일자를 생성한다', () => { + // Given: 2025-10-30까지 최대 일자를 생성하는 경우 + const event = { + date: '2025-01-01', + repeatType: 'daily' as const, + interval: 1, + endDate: '2025-10-30', + }; + + // When: 2025-10-30까지 최대 일자를 생성할 때 + const repeatDates = generateRepeatDates(event); + + // Then: 2025-10-30까지 최대 일자가 생성되어야 한다 + expect(repeatDates.length).toBe(303); // 1월 1일부터 10월 30일까지 총 303일 + expect(repeatDates[0]).toBe('2025-01-01'); // 시작일 + expect(repeatDates[repeatDates.length - 1]).toBe('2025-10-30'); // 종료일 + }); +}); From 0ed5d25f209993d13338fd4f61485a70f9678e1c Mon Sep 17 00:00:00 2001 From: xxziiko Date: Thu, 28 Aug 2025 04:03:14 +0900 Subject: [PATCH 13/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A8=EC=9D=BC=20=EC=88=98=EC=A0=95=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/eventStateManagement.spec.ts | 24 +++++++++++++++++++ src/__tests__/unit/eventUpdateUtils.spec.ts | 7 ++++++ 2 files changed, 31 insertions(+) create mode 100644 src/__tests__/unit/eventStateManagement.spec.ts create mode 100644 src/__tests__/unit/eventUpdateUtils.spec.ts diff --git a/src/__tests__/unit/eventStateManagement.spec.ts b/src/__tests__/unit/eventStateManagement.spec.ts new file mode 100644 index 00000000..ab58ef1b --- /dev/null +++ b/src/__tests__/unit/eventStateManagement.spec.ts @@ -0,0 +1,24 @@ +import { convertToSingleEvent, convertToRepeatingEvent } from '../../utils/eventStateManagement'; + +const createTestEvent = ( + title: string, + repeatType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' +) => ({ + id: `event-${title}`, + title, + repeat: { type: repeatType }, +}); + +describe('이벤트 상태 관리 함수 테스트', () => { + it('반복 일정을 단일 일정으로 변환한다', () => { + const repeatingEvent = createTestEvent('정기 회의', 'weekly'); + const result = convertToSingleEvent(repeatingEvent); + expect(result.repeat.type).toBe('none'); + }); + + it('단일 일정을 반복 일정으로 변환한다', () => { + const singleEvent = createTestEvent('일회성 미팅', 'none'); + const result = convertToRepeatingEvent(singleEvent, 'weekly', 1); + expect(result.repeat.type).toBe('weekly'); + }); +}); diff --git a/src/__tests__/unit/eventUpdateUtils.spec.ts b/src/__tests__/unit/eventUpdateUtils.spec.ts new file mode 100644 index 00000000..e3494436 --- /dev/null +++ b/src/__tests__/unit/eventUpdateUtils.spec.ts @@ -0,0 +1,7 @@ +describe('이벤트 업데이트 유틸리티 테스트', () => { + it('이벤트의 반복 설정을 업데이트한다', () => { + const event = createTestEvent('정기 회의', 'weekly'); + const updatedEvent = updateEventRepeat(event, { type: 'none', interval: 1 }); + expect(updatedEvent.repeat.type).toBe('none'); + }); +}); From c5b1be3470638cd0a3d646ca6f5c619df1c63790 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Thu, 28 Aug 2025 18:15:20 +0900 Subject: [PATCH 14/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=ED=95=A8=EC=88=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/unit/eventStateUtils.spec.ts | 59 ++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/__tests__/unit/eventStateUtils.spec.ts diff --git a/src/__tests__/unit/eventStateUtils.spec.ts b/src/__tests__/unit/eventStateUtils.spec.ts new file mode 100644 index 00000000..cc0537f8 --- /dev/null +++ b/src/__tests__/unit/eventStateUtils.spec.ts @@ -0,0 +1,59 @@ +import { + getRepeatingEvents, + groupEventsByRepeatType, + isRepeatingEvent, + getRepeatDisplayInfo, +} from '../../utils/eventStateUtils'; + +describe('반복 일정 판별 함수 테스트', () => { + it('반복 일정을 올바르게 판별한다', () => { + const repeatingEvent = { repeat: { type: 'weekly', interval: 1 } }; + const singleEvent = { repeat: { type: 'none', interval: 1 } }; + + expect(isRepeatingEvent(repeatingEvent)).toBe(true); + expect(isRepeatingEvent(singleEvent)).toBe(false); + }); + + it('반복 일정 표시 정보를 올바르게 생성한다', () => { + const weeklyEvent = { repeat: { type: 'weekly', interval: 2 } }; + const result = getRepeatDisplayInfo(weeklyEvent); + + expect(result.isRepeating).toBe(true); + expect(result.repeatText).toBe('2주마다'); + expect(result.shouldShowIcon).toBe(true); + }); + + it('단일 일정의 경우 아이콘을 표시하지 않는다', () => { + const singleEvent = { repeat: { type: 'none', interval: 1 } }; + const result = getRepeatDisplayInfo(singleEvent); + + expect(result.isRepeating).toBe(false); + expect(result.repeatText).toBe(''); + expect(result.shouldShowIcon).toBe(false); + }); +}); + +describe('반복 일정 필터링 함수 테스트', () => { + it('반복 일정만 필터링한다', () => { + const events = [ + { repeat: { type: 'weekly', interval: 1 } }, + { repeat: { type: 'none', interval: 1 } }, + { repeat: { type: 'monthly', interval: 1 } }, + ]; + + const repeatingEvents = getRepeatingEvents(events); + expect(repeatingEvents).toHaveLength(2); + }); + + it('반복 유형별로 일정을 그룹화한다', () => { + const events = [ + { repeat: { type: 'weekly', interval: 1 } }, + { repeat: { type: 'monthly', interval: 1 } }, + { repeat: { type: 'weekly', interval: 2 } }, + ]; + + const groups = groupEventsByRepeatType(events); + expect(groups.weekly).toHaveLength(2); + expect(groups.monthly).toHaveLength(1); + }); +}); From 508657c64841e51daecdc3b7358ba624832cdff1 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Thu, 28 Aug 2025 18:23:03 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat:=20=EB=B0=98=EB=B3=B5=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=9C=A0=EB=8B=9B=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/eventStateUtils.ts | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/utils/eventStateUtils.ts diff --git a/src/utils/eventStateUtils.ts b/src/utils/eventStateUtils.ts new file mode 100644 index 00000000..21bc191c --- /dev/null +++ b/src/utils/eventStateUtils.ts @@ -0,0 +1,59 @@ +import { Event } from '../types'; + +export const isRepeatingEvent = (event: Event) => { + return event.repeat.type !== 'none'; +}; + +export function getRepeatDisplayInfo(event: Event): { + isRepeating: boolean; + repeatText: string; + shouldShowIcon: boolean; +} { + const isRepeating = event.repeat.type !== 'none'; + + if (!isRepeating) { + return { isRepeating: false, repeatText: '', shouldShowIcon: false }; + } + + const typeText = { + daily: '일', + weekly: '주', + monthly: '월', + yearly: '년', + none: '', + }[event.repeat.type]; + + const repeatText = `${event.repeat.interval}${typeText}마다`; + + return { + isRepeating: true, + repeatText, + shouldShowIcon: true, + }; +} + +export function getRepeatingEvents(events: Event[]): Event[] { + return events.filter(isRepeatingEvent); +} + +export function groupEventsByRepeatType(events: Event[]): { + daily: Event[]; + weekly: Event[]; + monthly: Event[]; + yearly: Event[]; + none: Event[]; +} { + const initialGroups = { + daily: [] as Event[], + weekly: [] as Event[], + monthly: [] as Event[], + yearly: [] as Event[], + none: [] as Event[], + }; + + return events.reduce((groups, event) => { + const type = event.repeat.type; + groups[type].push(event); + return groups; + }, initialGroups); +} From 2c0c66a182fd3cf129c9c0d547846903b70c8288 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 02:49:06 +0900 Subject: [PATCH 16/34] =?UTF-8?q?refactor:=20createEvent=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/unit/eventStateUtils.spec.ts | 21 ++++++++-------- src/components/RepeatEventIcon.tsx | 28 +++++++++++++--------- src/utils/eventUtils.ts | 21 ++++++++++++++++ 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/__tests__/unit/eventStateUtils.spec.ts b/src/__tests__/unit/eventStateUtils.spec.ts index cc0537f8..07e200a9 100644 --- a/src/__tests__/unit/eventStateUtils.spec.ts +++ b/src/__tests__/unit/eventStateUtils.spec.ts @@ -4,18 +4,19 @@ import { isRepeatingEvent, getRepeatDisplayInfo, } from '../../utils/eventStateUtils'; +import { createEvent } from '../../utils/eventUtils'; describe('반복 일정 판별 함수 테스트', () => { it('반복 일정을 올바르게 판별한다', () => { - const repeatingEvent = { repeat: { type: 'weekly', interval: 1 } }; - const singleEvent = { repeat: { type: 'none', interval: 1 } }; + const repeatingEvent = createEvent('repeating', 'weekly', 1); + const singleEvent = createEvent('single', 'none', 1); expect(isRepeatingEvent(repeatingEvent)).toBe(true); expect(isRepeatingEvent(singleEvent)).toBe(false); }); it('반복 일정 표시 정보를 올바르게 생성한다', () => { - const weeklyEvent = { repeat: { type: 'weekly', interval: 2 } }; + const weeklyEvent = createEvent('repeating', 'weekly', 2); const result = getRepeatDisplayInfo(weeklyEvent); expect(result.isRepeating).toBe(true); @@ -24,7 +25,7 @@ describe('반복 일정 판별 함수 테스트', () => { }); it('단일 일정의 경우 아이콘을 표시하지 않는다', () => { - const singleEvent = { repeat: { type: 'none', interval: 1 } }; + const singleEvent = createEvent('single', 'none', 1); const result = getRepeatDisplayInfo(singleEvent); expect(result.isRepeating).toBe(false); @@ -36,9 +37,9 @@ describe('반복 일정 판별 함수 테스트', () => { describe('반복 일정 필터링 함수 테스트', () => { it('반복 일정만 필터링한다', () => { const events = [ - { repeat: { type: 'weekly', interval: 1 } }, - { repeat: { type: 'none', interval: 1 } }, - { repeat: { type: 'monthly', interval: 1 } }, + createEvent('repeating', 'weekly', 1), + createEvent('single', 'none', 1), + createEvent('repeating', 'monthly', 1), ]; const repeatingEvents = getRepeatingEvents(events); @@ -47,9 +48,9 @@ describe('반복 일정 필터링 함수 테스트', () => { it('반복 유형별로 일정을 그룹화한다', () => { const events = [ - { repeat: { type: 'weekly', interval: 1 } }, - { repeat: { type: 'monthly', interval: 1 } }, - { repeat: { type: 'weekly', interval: 2 } }, + createEvent('repeating', 'weekly', 1), + createEvent('repeating', 'monthly', 1), + createEvent('repeating', 'weekly', 2), ]; const groups = groupEventsByRepeatType(events); diff --git a/src/components/RepeatEventIcon.tsx b/src/components/RepeatEventIcon.tsx index 56644d09..10079a03 100644 --- a/src/components/RepeatEventIcon.tsx +++ b/src/components/RepeatEventIcon.tsx @@ -1,6 +1,8 @@ import { Repeat } from '@mui/icons-material'; import { Box, Tooltip } from '@mui/material'; +import { getRepeatDisplayInfo } from '../utils/eventStateUtils'; + interface RepeatEventIconProps { repeatType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; interval?: number; @@ -12,25 +14,29 @@ export function RepeatEventIcon({ repeatType, interval = 1, endDate }: RepeatEve return null; } - const getRepeatText = () => { - const typeText = { - daily: '일', - weekly: '주', - monthly: '월', - yearly: '년', - }[repeatType]; - - return `${interval}${typeText}마다${endDate ? ` (종료: ${endDate})` : ''}`; + const mockEvent = { + id: 'temp', + title: 'temp', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: repeatType, interval, endDate }, + notificationTime: 0, }; + const { repeatText } = getRepeatDisplayInfo(mockEvent); + return ( - + diff --git a/src/utils/eventUtils.ts b/src/utils/eventUtils.ts index 9e75e947..f8c6e59a 100644 --- a/src/utils/eventUtils.ts +++ b/src/utils/eventUtils.ts @@ -56,3 +56,24 @@ export function getFilteredEvents( return searchedEvents; } + +export function createEvent( + type: 'single' | 'repeating', + repeatType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly', + interval: number +): Event { + const title = type === 'single' ? 'Single Event' : `Repeating Event ${repeatType} ${interval}`; + + return { + id: `event-${type}-${repeatType}-${interval}`, + title, + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: repeatType, interval, endDate: undefined }, + notificationTime: 0, + }; +} From f0a2f5a74430b2b12186c10c8178a59d9b195f0e Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 02:50:22 +0900 Subject: [PATCH 17/34] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/repeatEventDisplay.spec.tsx | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 src/__tests__/unit/repeatEventDisplay.spec.tsx diff --git a/src/__tests__/unit/repeatEventDisplay.spec.tsx b/src/__tests__/unit/repeatEventDisplay.spec.tsx deleted file mode 100644 index f6b7e190..00000000 --- a/src/__tests__/unit/repeatEventDisplay.spec.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { RepeatEventIcon } from '../../components/RepeatEventIcon'; - -// 테스트용 일정 데이터 생성 함수 -const createTestEvent = ( - title: string, - repeatType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' -) => ({ - id: `event-${title}`, - title, - repeat: { type: repeatType }, -}); - -describe('반복 일정 표시 테스트', () => { - it('반복 일정에는 반복 아이콘이 표시된다', () => { - // Given: 반복 일정이 있는 경우 - const repeatingEvent = createTestEvent('정기 회의', 'weekly'); - - // When: 반복 일정을 렌더링할 때 - render(); - - // Then: 반복 아이콘이 표시되어야 한다 - expect(screen.getByTestId('repeat-icon')).toBeInTheDocument(); - }); - - it('반복이 아닌 일정에는 반복 아이콘이 표시되지 않는다', () => { - // Given: 반복이 아닌 일정이 있는 경우 - const nonRepeatingEvent = createTestEvent('일회성 미팅', 'none'); - - // When: 반복이 아닌 일정을 렌더링할 때 - render(); - - // Then: 반복 아이콘이 표시되지 않아야 한다 - expect(screen.queryByTestId('repeat-icon')).not.toBeInTheDocument(); - }); -}); From 07c9fbc6dfada7d0b220250d7144ed45a4526122 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 04:43:38 +0900 Subject: [PATCH 18/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A8=EC=9D=BC=20=EC=88=98=EC=A0=95=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/eventStateManagement.spec.ts | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/__tests__/unit/eventStateManagement.spec.ts b/src/__tests__/unit/eventStateManagement.spec.ts index ab58ef1b..7a0e62f0 100644 --- a/src/__tests__/unit/eventStateManagement.spec.ts +++ b/src/__tests__/unit/eventStateManagement.spec.ts @@ -1,4 +1,5 @@ -import { convertToSingleEvent, convertToRepeatingEvent } from '../../utils/eventStateManagement'; +import { RepeatType } from '../../types'; +import { convertToSingleEvent, convertToRepeatingEvent } from '../../utils/eventStateUtils'; const createTestEvent = ( title: string, @@ -9,16 +10,64 @@ const createTestEvent = ( repeat: { type: repeatType }, }); -describe('이벤트 상태 관리 함수 테스트', () => { +describe('convertToSingleEvent', () => { it('반복 일정을 단일 일정으로 변환한다', () => { const repeatingEvent = createTestEvent('정기 회의', 'weekly'); const result = convertToSingleEvent(repeatingEvent); + expect(result.repeat.type).toBe('none'); + expect(result.repeat.interval).toBe(1); + expect(result.repeat.endDate).toBeUndefined(); + expect(result.title).toBe('정기 회의'); // 다른 필드 보존 확인 }); + it('이미 단일 일정인 경우에도 안전하게 처리한다', () => { + const singleEvent = createTestEvent('일회성 미팅', 'none'); + const result = convertToSingleEvent(singleEvent); + + expect(result.repeat.type).toBe('none'); + expect(result.repeat.interval).toBe(1); + }); + + it('모든 반복 유형을 단일 일정으로 변환한다', () => { + const types: RepeatType[] = ['daily', 'weekly', 'monthly', 'yearly']; + + types.forEach((type) => { + const event = createTestEvent(`테스트 ${type}`, type); + const result = convertToSingleEvent(event); + expect(result.repeat.type).toBe('none'); + }); + }); +}); + +describe('convertToRepeatingEvent', () => { it('단일 일정을 반복 일정으로 변환한다', () => { const singleEvent = createTestEvent('일회성 미팅', 'none'); - const result = convertToRepeatingEvent(singleEvent, 'weekly', 1); + const result = convertToRepeatingEvent(singleEvent, 'weekly', 2); + expect(result.repeat.type).toBe('weekly'); + expect(result.repeat.interval).toBe(2); + expect(result.repeat.endDate).toBeUndefined(); + }); + + it('다양한 반복 유형으로 변환한다', () => { + const singleEvent = createTestEvent('테스트', 'none'); + const types: RepeatType[] = ['daily', 'weekly', 'monthly', 'yearly']; + + types.forEach((type) => { + const result = convertToRepeatingEvent(singleEvent, type, 1); + expect(result.repeat.type).toBe(type); + expect(result.repeat.interval).toBe(1); + }); + }); + + it('다양한 간격으로 변환한다', () => { + const singleEvent = createTestEvent('테스트', 'none'); + const intervals = [1, 2, 3, 7]; + + intervals.forEach((interval) => { + const result = convertToRepeatingEvent(singleEvent, 'weekly', interval); + expect(result.repeat.interval).toBe(interval); + }); }); }); From 41348ce63a45b85e0383acc800efb5bfd43288ed Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 04:55:42 +0900 Subject: [PATCH 19/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A8=EC=9D=BC=20=EC=88=98=EC=A0=95=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/eventStateManagement.spec.ts | 73 ------------------ src/__tests__/unit/eventUpdateUtils.spec.ts | 77 +++++++++++++++++-- 2 files changed, 72 insertions(+), 78 deletions(-) delete mode 100644 src/__tests__/unit/eventStateManagement.spec.ts diff --git a/src/__tests__/unit/eventStateManagement.spec.ts b/src/__tests__/unit/eventStateManagement.spec.ts deleted file mode 100644 index 7a0e62f0..00000000 --- a/src/__tests__/unit/eventStateManagement.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { RepeatType } from '../../types'; -import { convertToSingleEvent, convertToRepeatingEvent } from '../../utils/eventStateUtils'; - -const createTestEvent = ( - title: string, - repeatType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' -) => ({ - id: `event-${title}`, - title, - repeat: { type: repeatType }, -}); - -describe('convertToSingleEvent', () => { - it('반복 일정을 단일 일정으로 변환한다', () => { - const repeatingEvent = createTestEvent('정기 회의', 'weekly'); - const result = convertToSingleEvent(repeatingEvent); - - expect(result.repeat.type).toBe('none'); - expect(result.repeat.interval).toBe(1); - expect(result.repeat.endDate).toBeUndefined(); - expect(result.title).toBe('정기 회의'); // 다른 필드 보존 확인 - }); - - it('이미 단일 일정인 경우에도 안전하게 처리한다', () => { - const singleEvent = createTestEvent('일회성 미팅', 'none'); - const result = convertToSingleEvent(singleEvent); - - expect(result.repeat.type).toBe('none'); - expect(result.repeat.interval).toBe(1); - }); - - it('모든 반복 유형을 단일 일정으로 변환한다', () => { - const types: RepeatType[] = ['daily', 'weekly', 'monthly', 'yearly']; - - types.forEach((type) => { - const event = createTestEvent(`테스트 ${type}`, type); - const result = convertToSingleEvent(event); - expect(result.repeat.type).toBe('none'); - }); - }); -}); - -describe('convertToRepeatingEvent', () => { - it('단일 일정을 반복 일정으로 변환한다', () => { - const singleEvent = createTestEvent('일회성 미팅', 'none'); - const result = convertToRepeatingEvent(singleEvent, 'weekly', 2); - - expect(result.repeat.type).toBe('weekly'); - expect(result.repeat.interval).toBe(2); - expect(result.repeat.endDate).toBeUndefined(); - }); - - it('다양한 반복 유형으로 변환한다', () => { - const singleEvent = createTestEvent('테스트', 'none'); - const types: RepeatType[] = ['daily', 'weekly', 'monthly', 'yearly']; - - types.forEach((type) => { - const result = convertToRepeatingEvent(singleEvent, type, 1); - expect(result.repeat.type).toBe(type); - expect(result.repeat.interval).toBe(1); - }); - }); - - it('다양한 간격으로 변환한다', () => { - const singleEvent = createTestEvent('테스트', 'none'); - const intervals = [1, 2, 3, 7]; - - intervals.forEach((interval) => { - const result = convertToRepeatingEvent(singleEvent, 'weekly', interval); - expect(result.repeat.interval).toBe(interval); - }); - }); -}); diff --git a/src/__tests__/unit/eventUpdateUtils.spec.ts b/src/__tests__/unit/eventUpdateUtils.spec.ts index e3494436..509634a8 100644 --- a/src/__tests__/unit/eventUpdateUtils.spec.ts +++ b/src/__tests__/unit/eventUpdateUtils.spec.ts @@ -1,7 +1,74 @@ -describe('이벤트 업데이트 유틸리티 테스트', () => { - it('이벤트의 반복 설정을 업데이트한다', () => { - const event = createTestEvent('정기 회의', 'weekly'); - const updatedEvent = updateEventRepeat(event, { type: 'none', interval: 1 }); - expect(updatedEvent.repeat.type).toBe('none'); +import { updateEventRepeat } from '../../utils/eventUpdateUtils'; +import { createEvent } from '../../utils/eventUtils'; + +describe('이벤트 반복 설정 업데이트 유틸리티 테스트', () => { + describe('핵심 기능', () => { + it('반복 일정을 단일 일정으로 변환한다', () => { + const weeklyEvent = createEvent('repeating', 'weekly', 1); + const updatedEvent = updateEventRepeat(weeklyEvent, { type: 'none', interval: 1 }); + + expect(updatedEvent.repeat.type).toBe('none'); + expect(updatedEvent.repeat.interval).toBe(1); + expect(updatedEvent.repeat.endDate).toBeUndefined(); + }); + + it('단일 일정을 반복 일정으로 변환한다', () => { + const singleEvent = createEvent('single', 'none', 1); + const updatedEvent = updateEventRepeat(singleEvent, { type: 'weekly', interval: 2 }); + + expect(updatedEvent.repeat.type).toBe('weekly'); + expect(updatedEvent.repeat.interval).toBe(2); + }); + + it('다양한 반복 유형으로 변환한다', () => { + const singleEvent = createEvent('single', 'none', 1); + const types = ['daily', 'weekly', 'monthly', 'yearly'] as const; + + types.forEach((type) => { + const updatedEvent = updateEventRepeat(singleEvent, { type, interval: 1 }); + expect(updatedEvent.repeat.type).toBe(type); + expect(updatedEvent.repeat.interval).toBe(1); + }); + }); + }); + + describe('데이터 무결성', () => { + it('원본 객체를 변경하지 않는다 (불변성)', () => { + const originalEvent = createEvent('repeating', 'weekly', 1); + const originalRepeat = { ...originalEvent.repeat }; + + updateEventRepeat(originalEvent, { type: 'none', interval: 1 }); + + expect(originalEvent.repeat).toEqual(originalRepeat); + }); + + it('다른 필드들이 보존된다', () => { + const event = createEvent('repeating', 'weekly', 2); + const updatedEvent = updateEventRepeat(event, { type: 'none', interval: 1 }); + + // 핵심 필드만 확인 + expect(updatedEvent.title).toBe('테스트'); + expect(updatedEvent.date).toBe('2025-01-01'); + expect(updatedEvent.startTime).toBe('09:00'); + expect(updatedEvent.endTime).toBe('10:00'); + }); + }); + + describe('엣지 케이스', () => { + it('이미 단일 일정인 경우에도 안전하게 처리한다', () => { + const singleEvent = createEvent('single', 'none', 1); + const updatedEvent = updateEventRepeat(singleEvent, { type: 'none', interval: 1 }); + + expect(updatedEvent.repeat.type).toBe('none'); + expect(updatedEvent.repeat.interval).toBe(1); + }); + + it('동일한 반복 설정으로 업데이트해도 안전하게 처리한다', () => { + const weeklyEvent = createEvent('repeating', 'weekly', 1); + const updatedEvent = updateEventRepeat(weeklyEvent, { type: 'weekly', interval: 1 }); + + expect(updatedEvent.repeat.type).toBe('weekly'); + expect(updatedEvent.repeat.interval).toBe(1); + }); }); }); From 4517befd37898e381e8ceae2bc3a6c589c5a90e9 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 05:30:49 +0900 Subject: [PATCH 20/34] =?UTF-8?q?feat:=20updateEventRepeat=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/eventUpdateUtils.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/utils/eventUpdateUtils.ts diff --git a/src/utils/eventUpdateUtils.ts b/src/utils/eventUpdateUtils.ts new file mode 100644 index 00000000..d141b4cc --- /dev/null +++ b/src/utils/eventUpdateUtils.ts @@ -0,0 +1,14 @@ +import { Event, RepeatInfo } from '../types'; + +/** + * 이벤트의 반복 설정을 업데이트하는 함수 + * @param event - 업데이트할 이벤트 + * @param newRepeat - 새로운 반복 설정 + * @returns 업데이트된 이벤트 (원본 객체는 변경하지 않음) + */ +export const updateEventRepeat = (event: Event, newRepeat: RepeatInfo): Event => { + return { + ...event, + repeat: newRepeat, + }; +}; From d97362c9f151a3b75f44ba1d70848c539967b44d Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 05:31:04 +0900 Subject: [PATCH 21/34] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20exp?= =?UTF-8?q?ect=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/unit/eventUpdateUtils.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/__tests__/unit/eventUpdateUtils.spec.ts b/src/__tests__/unit/eventUpdateUtils.spec.ts index 509634a8..e352e561 100644 --- a/src/__tests__/unit/eventUpdateUtils.spec.ts +++ b/src/__tests__/unit/eventUpdateUtils.spec.ts @@ -46,11 +46,14 @@ describe('이벤트 반복 설정 업데이트 유틸리티 테스트', () => { const event = createEvent('repeating', 'weekly', 2); const updatedEvent = updateEventRepeat(event, { type: 'none', interval: 1 }); - // 핵심 필드만 확인 - expect(updatedEvent.title).toBe('테스트'); + expect(updatedEvent.title).toBe('Repeating Event weekly 2'); expect(updatedEvent.date).toBe('2025-01-01'); expect(updatedEvent.startTime).toBe('09:00'); expect(updatedEvent.endTime).toBe('10:00'); + expect(updatedEvent.description).toBe(''); + expect(updatedEvent.location).toBe(''); + expect(updatedEvent.category).toBe('업무'); + expect(updatedEvent.notificationTime).toBe(0); }); }); From 979c01e00da42d270ab0b38858688a90fb213cf3 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 05:38:04 +0900 Subject: [PATCH 22/34] =?UTF-8?q?feat:=20=EB=B0=98=EB=B3=B5=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20-=20=EB=A7=A4?= =?UTF-8?q?=EC=9D=BC/=EB=A7=A4=EC=A3=BC/=EB=A7=A4=EC=9B=94/=EB=A7=A4?= =?UTF-8?q?=EB=85=84=20=EB=B0=98=EB=B3=B5=20=EC=9C=A0=ED=98=95=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20-=20=EB=B0=98=EB=B3=B5=20=EA=B0=84=EA=B2=A9=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20-=20=EB=B0=98=EB=B3=B5=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=EC=9D=BC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/repeatEventSelection.spec.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/__tests__/unit/repeatEventSelection.spec.ts diff --git a/src/__tests__/unit/repeatEventSelection.spec.ts b/src/__tests__/unit/repeatEventSelection.spec.ts new file mode 100644 index 00000000..97e5128e --- /dev/null +++ b/src/__tests__/unit/repeatEventSelection.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { RepeatType } from '../../types'; + +describe('반복 유형 선택 기능', () => { + describe('반복 유형 옵션', () => { + it('매일 반복을 선택할 수 있다', () => { + // Given: 반복 일정 체크박스가 체크된 상태 + const isRepeating = true; + + // When: 매일 반복을 선택 + const selectedRepeatType: RepeatType = 'daily'; + + // Then: 매일 반복이 선택되어야 함 + expect(selectedRepeatType).toBe('daily'); + expect(isRepeating).toBe(true); + }); + + it('매주 반복을 선택할 수 있다', () => { + // Given: 반복 일정 체크박스가 체크된 상태 + const isRepeating = true; + + // When: 매주 반복을 선택 + const selectedRepeatType: RepeatType = 'weekly'; + + // Then: 매주 반복이 선택되어야 함 + expect(selectedRepeatType).toBe('weekly'); + expect(isRepeating).toBe(true); + }); + + it('매월 반복을 선택할 수 있다', () => { + // Given: 반복 일정 체크박스가 체크된 상태 + const isRepeating = true; + + // When: 매월 반복을 선택 + const selectedRepeatType: RepeatType = 'monthly'; + + // Then: 매월 반복이 선택되어야 함 + expect(selectedRepeatType).toBe('monthly'); + expect(isRepeating).toBe(true); + }); + + it('매년 반복을 선택할 수 있다', () => { + // Given: 반복 일정 체크박스가 체크된 상태 + const isRepeating = true; + + // When: 매년 반복을 선택 + const selectedRepeatType: RepeatType = 'yearly'; + + // Then: 매년 반복이 선택되어야 함 + expect(selectedRepeatType).toBe('yearly'); + expect(isRepeating).toBe(true); + }); + }); + + describe('반복 간격 설정', () => { + it('반복 간격을 설정할 수 있다', () => { + // Given: 반복 일정이 활성화된 상태 + const isRepeating = true; + const repeatType: RepeatType = 'weekly'; + + // When: 반복 간격을 2로 설정 + const repeatInterval = 2; + + // Then: 반복 간격이 2로 설정되어야 함 + expect(repeatInterval).toBe(2); + expect(isRepeating).toBe(true); + expect(repeatType).toBe('weekly'); + }); + + it('반복 간격은 최소 1 이상이어야 한다', () => { + // Given: 반복 일정이 활성화된 상태 + const isRepeating = true; + + // When: 반복 간격을 1로 설정 + const repeatInterval = 1; + + // Then: 반복 간격이 1 이상이어야 함 + expect(repeatInterval).toBeGreaterThanOrEqual(1); + expect(isRepeating).toBe(true); + }); + }); + + describe('반복 종료일 설정', () => { + it('반복 종료일을 설정할 수 있다', () => { + // Given: 반복 일정이 활성화된 상태 + const isRepeating = true; + const repeatType: RepeatType = 'monthly'; + + // When: 반복 종료일을 2025-10-30으로 설정 + const repeatEndDate = '2025-10-30'; + + // Then: 반복 종료일이 설정되어야 함 + expect(repeatEndDate).toBe('2025-10-30'); + expect(isRepeating).toBe(true); + expect(repeatType).toBe('monthly'); + }); + + it('반복 종료일이 없을 수도 있다', () => { + // Given: 반복 일정이 활성화된 상태 + const isRepeating = true; + + // When: 반복 종료일을 설정하지 않음 + const repeatEndDate = undefined; + + // Then: 반복 종료일이 undefined일 수 있어야 함 + expect(repeatEndDate).toBeUndefined(); + expect(isRepeating).toBe(true); + }); + }); +}); From e1240bad869c3a1fc215d4455cfefe0dc4f1144c Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 05:47:06 +0900 Subject: [PATCH 23/34] =?UTF-8?q?feat:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=99=84=EC=84=B1=20-=20=EB=A7=A4=EC=9D=BC/?= =?UTF-8?q?=EB=A7=A4=EC=A3=BC/=EB=A7=A4=EC=9B=94/=EB=A7=A4=EB=85=84=20?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=20=EC=9D=BC=EC=A0=95=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20-=20=EC=9C=A4?= =?UTF-8?q?=EB=85=84=20=EB=B0=8F=20=EA=B2=BD=EA=B3=84=EA=B0=92=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=2031?= =?UTF-8?q?=EC=9D=BC=20=EB=A7=A4=EC=9B=94=20=EB=B0=98=EB=B3=B5=20=EC=8B=9C?= =?UTF-8?q?=202=EC=9B=94=EC=9D=80=2028=EC=9D=BC,=204=EC=9B=94=EC=9D=80=203?= =?UTF-8?q?0=EC=9D=BC=EC=97=90=20=EC=83=9D=EC=84=B1=20-=202=EC=9B=94=2029?= =?UTF-8?q?=EC=9D=BC=20=EB=A7=A4=EB=85=84=20=EB=B0=98=EB=B3=B5=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9C=A4=EB=85=84=EC=9D=B4=20=EC=95=84=EB=8B=8C=20=ED=95=B4?= =?UTF-8?q?=EC=97=90=EB=8A=94=2028=EC=9D=BC=EC=97=90=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/repeatEventGeneration.spec.ts | 282 ++++++++++++------ src/utils/repeatEventUtils.ts | 38 +-- 2 files changed, 203 insertions(+), 117 deletions(-) diff --git a/src/__tests__/unit/repeatEventGeneration.spec.ts b/src/__tests__/unit/repeatEventGeneration.spec.ts index 5d176c97..bd9aea35 100644 --- a/src/__tests__/unit/repeatEventGeneration.spec.ts +++ b/src/__tests__/unit/repeatEventGeneration.spec.ts @@ -1,118 +1,230 @@ +import { describe, it, expect } from 'vitest'; + +import { Event } from '../../types'; import { generateRepeatDates } from '../../utils/repeatEventUtils'; -describe('반복 일정 생성 테스트', () => { - describe('매월 반복', () => { - it('31일에 매월 반복을 선택하면 2월을 제외한 매월 31일에 일정을 생성한다', () => { - // Given: 31일에 매월 반복 일정을 생성하는 경우 - const event = { - date: '2025-01-31', - repeatType: 'monthly' as const, +describe('반복 일정 생성 및 관리 기능', () => { + describe('반복 일정 실제 생성', () => { + it('매일 반복 일정을 생성할 수 있다', () => { + // Given: 매일 반복하는 일정 설정 + const eventData = { + date: '2025-01-01', + repeatType: 'daily' as const, interval: 1, - endDate: '2025-10-30', + endDate: '2025-01-05', }; - // When: 반복 일정을 생성할 때 - const repeatDates = generateRepeatDates(event); + // When: 반복 날짜 생성 + const repeatDates = generateRepeatDates(eventData); - // Then: 2월을 제외한 매월 31일에만 일정이 생성되어야 한다 + // Then: 5일간의 반복 날짜가 생성되어야 함 + expect(repeatDates).toHaveLength(5); expect(repeatDates).toEqual([ - '2025-01-31', - '2025-03-31', // 2월은 28일이므로 제외 - '2025-05-31', - '2025-07-31', - '2025-08-31', + '2025-01-01', + '2025-01-02', + '2025-01-03', + '2025-01-04', + '2025-01-05', ]); }); - }); - describe('매년 반복', () => { - it('윤년 2월 29일에 매년 반복을 선택하면 매년 2월 29일에만 일정을 생성한다', () => { - // Given: 윤년 2월 29일에 매년 반복 일정을 생성하는 경우 - const event = { - date: '2024-02-29', // 윤년 - repeatType: 'yearly' as const, + it('매주 반복 일정을 생성할 수 있다', () => { + // Given: 매주 반복하는 일정 설정 + const eventData = { + date: '2025-01-01', + repeatType: 'weekly' as const, interval: 1, - endDate: '2028-02-29', + endDate: '2025-01-29', }; - // When: 반복 일정을 생성할 때 - const repeatDates = generateRepeatDates(event); + // When: 반복 날짜 생성 + const repeatDates = generateRepeatDates(eventData); - // Then: 윤년의 2월 29일에만 일정이 생성되어야 한다 - expect(repeatDates).toEqual(['2024-02-29', '2028-02-29']); + // Then: 5주간의 반복 날짜가 생성되어야 함 + expect(repeatDates).toHaveLength(5); + expect(repeatDates[0]).toBe('2025-01-01'); + expect(repeatDates[1]).toBe('2025-01-08'); + expect(repeatDates[2]).toBe('2025-01-15'); + expect(repeatDates[3]).toBe('2025-01-22'); + expect(repeatDates[4]).toBe('2025-01-29'); }); - }); - describe('기본 반복 유형', () => { - it('매일 반복을 선택하면 시작일부터 종료일까지 매일 일정을 생성한다', () => { - const event = { - date: '2025-01-01', - repeatType: 'daily' as const, + it('매월 반복 일정을 생성할 수 있다', () => { + // Given: 매월 반복하는 일정 설정 (1월 31일) + const eventData = { + date: '2025-01-31', + repeatType: 'monthly' as const, interval: 1, - endDate: '2025-01-05', + endDate: '2025-05-31', }; - const repeatDates = generateRepeatDates(event); + // When: 반복 날짜 생성 + const repeatDates = generateRepeatDates(eventData); - expect(repeatDates).toEqual([ - '2025-01-01', - '2025-01-02', - '2025-01-03', - '2025-01-04', - '2025-01-05', - ]); + // Then: 5개월간의 반복 날짜가 생성되어야 함 + expect(repeatDates).toHaveLength(5); + expect(repeatDates[0]).toBe('2025-01-31'); + expect(repeatDates[1]).toBe('2025-02-28'); // 2월은 28일까지만 + expect(repeatDates[2]).toBe('2025-03-31'); + expect(repeatDates[3]).toBe('2025-04-30'); // 4월은 30일까지만 + expect(repeatDates[4]).toBe('2025-05-31'); }); - it('매주 반복을 선택하면 7일 간격으로 일정을 생성한다', () => { - const event = { - date: '2025-01-01', // 수요일 - repeatType: 'weekly' as const, + it('매년 반복 일정을 생성할 수 있다', () => { + // Given: 매년 반복하는 일정 설정 (2월 29일) + const eventData = { + date: '2024-02-29', // 윤년 + repeatType: 'yearly' as const, interval: 1, - endDate: '2025-01-29', + endDate: '2028-02-29', }; - const repeatDates = generateRepeatDates(event); + // When: 반복 날짜 생성 + const repeatDates = generateRepeatDates(eventData); - expect(repeatDates).toEqual([ - '2025-01-01', - '2025-01-08', - '2025-01-15', - '2025-01-22', - '2025-01-29', - ]); + // Then: 윤년에는 2월 29일, 윤년이 아닌 해에는 2월 28일에 생성되어야 함 + expect(repeatDates).toHaveLength(5); + expect(repeatDates[0]).toBe('2024-02-29'); // 윤년 + expect(repeatDates[1]).toBe('2025-02-28'); // 윤년 아님 + expect(repeatDates[2]).toBe('2026-02-28'); // 윤년 아님 + expect(repeatDates[3]).toBe('2027-02-28'); // 윤년 아님 + expect(repeatDates[4]).toBe('2028-02-29'); // 윤년 }); }); -}); -describe('반복 일정 생성 - 경계값 테스트', () => { - it('반복 종료일이 시작일보다 이전이면 빈 배열을 반환한다', () => { - // Given: 시작일이 2025-01-31이고, 종료일이 2024-12-31인 경우 - const startDate = '2025-01-31'; - const endDate = '2024-12-31'; - const event = { - date: startDate, - repeatType: 'monthly' as const, - interval: 1, - endDate: endDate, - }; - - // When: 반복 일정을 생성할 때 - const repeatDates = generateRepeatDates(event); - - // Then: 빈 배열이 반환되어야 한다 - expect(repeatDates).toEqual([]); - }); -}); + describe('반복 일정 저장 및 관리', () => { + it('생성된 반복 일정들을 저장할 수 있다', () => { + // Given: 반복 일정 생성 데이터 + const baseEvent: Event = { + id: '1', + title: '팀 미팅', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }; -describe('날짜 형식 및 유효성', () => { - it('잘못된 날짜 형식에 대해 에러를 발생시킨다', () => { - const event = { - date: 'invalid-date', - repeatType: 'daily' as const, - interval: 1, - endDate: '2025-01-05', - }; + // When: 반복 일정들을 생성하여 저장 + const repeatDates = generateRepeatDates({ + date: baseEvent.date, + repeatType: baseEvent.repeat.type, + interval: baseEvent.repeat.interval, + endDate: baseEvent.repeat.endDate!, + }); + + const repeatEvents = repeatDates.map((date, index) => ({ + ...baseEvent, + id: `${baseEvent.id}-${index + 1}`, + date, + })); + + // Then: 모든 반복 일정이 생성되어야 함 + expect(repeatEvents).toHaveLength(5); + expect(repeatEvents[0].date).toBe('2025-01-01'); + expect(repeatEvents[1].date).toBe('2025-01-08'); + expect(repeatEvents[2].date).toBe('2025-01-15'); + expect(repeatEvents[3].date).toBe('2025-01-22'); + expect(repeatEvents[4].date).toBe('2025-01-29'); + }); + + it('반복 일정 중 특정 일정만 수정할 수 있다', () => { + // Given: 반복 일정들 + const repeatEvents: Event[] = [ + { + id: '1-1', + title: '팀 미팅', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + { + id: '1-2', + title: '팀 미팅', + date: '2025-01-08', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + ]; + + // When: 두 번째 일정만 수정 + const editedEvent: Event = { + ...repeatEvents[1], + title: '팀 미팅 (수정됨)', + startTime: '10:00', + endTime: '11:00', + repeat: { type: 'none', interval: 1, endDate: undefined }, + }; - expect(() => generateRepeatDates(event)).toThrow('Invalid date format'); + // Then: 수정된 일정은 단일 일정이 되어야 함 + expect(editedEvent.repeat.type).toBe('none'); + expect(editedEvent.title).toBe('팀 미팅 (수정됨)'); + expect(editedEvent.startTime).toBe('10:00'); + expect(editedEvent.endTime).toBe('11:00'); + }); + + it('반복 일정 중 특정 일정만 삭제할 수 있다', () => { + // Given: 반복 일정들 + const repeatEvents: Event[] = [ + { + id: '1-1', + title: '팀 미팅', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + { + id: '1-2', + title: '팀 미팅', + date: '2025-01-08', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + { + id: '1-3', + title: '팀 미팅', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + ]; + + // When: 두 번째 일정만 삭제 + const remainingEvents = repeatEvents.filter((event) => event.id !== '1-2'); + + // Then: 삭제된 일정을 제외한 나머지만 남아야 함 + expect(remainingEvents).toHaveLength(2); + expect(remainingEvents[0].id).toBe('1-1'); + expect(remainingEvents[1].id).toBe('1-3'); + expect(remainingEvents.find((event) => event.id === '1-2')).toBeUndefined(); + }); }); }); diff --git a/src/utils/repeatEventUtils.ts b/src/utils/repeatEventUtils.ts index 67c080e8..87acd33d 100644 --- a/src/utils/repeatEventUtils.ts +++ b/src/utils/repeatEventUtils.ts @@ -79,28 +79,11 @@ const generateMonthlyRepeatDates = (event: RepeatEventInput): string[] => { let nextYear = currentDate.getFullYear() + Math.floor(nextMonth / 12); nextMonth = nextMonth % 12; - // 원래 날짜가 다음 달에 존재하지 않는 경우 해당 달을 건너뛰기 + // 원래 날짜가 다음 달에 존재하지 않는 경우 해당 달의 마지막 날로 설정 const lastDayOfNextMonth = getLastDayOfMonth(nextYear, nextMonth); + const nextDay = Math.min(originalDay, lastDayOfNextMonth); - if (originalDay > lastDayOfNextMonth) { - // 해당 달을 건너뛰고 다음 달의 같은 날짜로 설정 - nextMonth = nextMonth + 1; - if (nextMonth >= 12) { - nextMonth = 0; - nextYear += 1; - } - - // 다음 달도 원래 날짜가 없으면 계속 건너뛰기 - while (getLastDayOfMonth(nextYear, nextMonth) < originalDay) { - nextMonth += 1; - if (nextMonth >= 12) { - nextMonth = 0; - nextYear += 1; - } - } - } - - const nextDate = new Date(nextYear, nextMonth, originalDay); + const nextDate = new Date(nextYear, nextMonth, nextDay); // endDate를 초과하면 종료 if (nextDate > endDate) { @@ -126,20 +109,11 @@ const generateYearlyRepeatDates = (event: RepeatEventInput): string[] => { // 다음 해 계산 let nextYear = currentDate.getFullYear() + event.interval; - // 원래 날짜가 다음 해에 존재하지 않는 경우 해당 해를 건너뛰기 + // 원래 날짜가 다음 해에 존재하지 않는 경우 해당 해의 마지막 날로 설정 const lastDayOfNextMonth = getLastDayOfMonth(nextYear, originalMonth); + const nextDay = Math.min(originalDay, lastDayOfNextMonth); - if (originalDay > lastDayOfNextMonth) { - // 해당 해를 건너뛰고 다음 해의 같은 날짜로 설정 - nextYear += 1; - - // 다음 해도 원래 날짜가 없으면 계속 건너뛰기 - while (getLastDayOfMonth(nextYear, originalMonth) < originalDay) { - nextYear += 1; - } - } - - const nextDate = new Date(nextYear, originalMonth, originalDay); + const nextDate = new Date(nextYear, originalMonth, nextDay); // endDate를 초과하면 종료 if (nextDate > endDate) { From 0cd96600bd62d710ebe4d6063497df07ad991826 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 05:52:40 +0900 Subject: [PATCH 24/34] =?UTF-8?q?test:=20=EB=8B=A8=EC=9D=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EC=9C=A0=EB=8B=9B=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/repeatEventSingleEdit.spec.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 src/__tests__/unit/repeatEventSingleEdit.spec.ts diff --git a/src/__tests__/unit/repeatEventSingleEdit.spec.ts b/src/__tests__/unit/repeatEventSingleEdit.spec.ts new file mode 100644 index 00000000..719a5f78 --- /dev/null +++ b/src/__tests__/unit/repeatEventSingleEdit.spec.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; + +import { Event } from '../../types'; +import { createSingleEditEvent, isRepeatEvent } from '../../utils/repeatEventUtils'; + +describe('반복 일정 단일 수정 기능', () => { + describe('단일 수정 시 반복 일정을 단일 일정으로 변경', () => { + it('반복 일정을 수정하면 단일 일정으로 변경된다', () => { + // Given: 매주 반복하는 일정 + const originalEvent: Event = { + id: '1', + title: '팀 미팅', + date: '2025-01-06', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-12-31' }, + notificationTime: 10, + }; + + // When: 해당 일정을 단일 수정 + const singleEditEvent = createSingleEditEvent(originalEvent, '2025-01-06'); + + // Then: 반복 설정이 'none'으로 변경되어야 함 + expect(singleEditEvent.repeat.type).toBe('none'); + expect(singleEditEvent.id).not.toBe(originalEvent.id); + expect(singleEditEvent.date).toBe('2025-01-06'); + }); + + it('단일 수정된 일정은 반복 아이콘이 표시되지 않는다', () => { + // Given: 단일 수정된 일정 + const editedEvent: Event = { + id: '1', + title: '팀 미팅 (수정됨)', + date: '2025-01-06', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1, endDate: undefined }, + notificationTime: 10, + }; + + // When: 반복 아이콘 표시 여부 확인 + const shouldShowRepeatIcon = isRepeatEvent(editedEvent); + + // Then: 반복 아이콘이 표시되지 않아야 함 + expect(shouldShowRepeatIcon).toBe(false); + expect(editedEvent.repeat.type).toBe('none'); + }); + + it('단일 수정 시 원본 반복 일정은 그대로 유지된다', () => { + // Given: 원본 반복 일정과 단일 수정된 일정 + const originalRepeatEvent: Event = { + id: '1', + title: '팀 미팅', + date: '2025-01-06', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-12-31' }, + notificationTime: 10, + }; + + const singleEditedEvent = createSingleEditEvent(originalRepeatEvent, '2025-01-06'); + + // When: 두 일정의 반복 설정 비교 + const originalIsRepeating = isRepeatEvent(originalRepeatEvent); + const editedIsRepeating = isRepeatEvent(singleEditedEvent); + + // Then: 원본은 반복 일정으로 유지되고, 수정된 것은 단일 일정이 되어야 함 + expect(originalIsRepeating).toBe(true); + expect(editedIsRepeating).toBe(false); + expect(originalRepeatEvent.repeat.type).toBe('weekly'); + expect(singleEditedEvent.repeat.type).toBe('none'); + }); + + it('단일 수정된 일정은 고유한 ID를 가져야 한다', () => { + // Given: 반복 일정 + const originalEvent: Event = { + id: '1', + title: '팀 미팅', + date: '2025-01-06', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-12-31' }, + notificationTime: 10, + }; + + // When: 단일 수정 이벤트 생성 + const singleEditEvent = createSingleEditEvent(originalEvent, '2025-01-06'); + + // Then: 고유한 ID를 가져야 함 + expect(singleEditEvent.id).not.toBe(originalEvent.id); + expect(singleEditEvent.id).toContain(originalEvent.id); + expect(singleEditEvent.id).toContain('single-edit'); + }); + }); + + describe('단일 수정 UI 동작', () => { + it('반복 일정에 수정 버튼을 클릭하면 단일 수정 옵션이 표시된다', () => { + // Given: 반복 일정 + const repeatEvent: Event = { + id: '1', + title: '팀 미팅', + date: '2025-01-06', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-12-31' }, + notificationTime: 10, + }; + + // When: 수정 버튼 클릭 시 단일 수정 옵션 확인 + const hasSingleEditOption = isRepeatEvent(repeatEvent); + + // Then: 단일 수정 옵션이 표시되어야 함 + expect(hasSingleEditOption).toBe(true); + expect(repeatEvent.repeat.type).toBe('weekly'); + }); + + it('단일 수정 옵션을 선택하면 수정 모드로 진입한다', () => { + // Given: 반복 일정 수정 모드 + const isEditingMode = true; + const isSingleEdit = true; + + // When: 단일 수정 모드 진입 + const shouldEnterEditMode = isEditingMode && isSingleEdit; + + // Then: 수정 모드로 진입해야 함 + expect(shouldEnterEditMode).toBe(true); + expect(isEditingMode).toBe(true); + expect(isSingleEdit).toBe(true); + }); + }); + + describe('단일 수정 시나리오', () => { + it('매일 반복 일정의 특정 날짜를 단일 수정할 수 있다', () => { + // Given: 매일 반복 일정 + const dailyRepeatEvent: Event = { + id: 'daily-1', + title: '아침 운동', + date: '2025-01-01', + startTime: '06:00', + endTime: '07:00', + description: '매일 아침 운동', + location: '집', + category: '개인', + repeat: { type: 'daily', interval: 1, endDate: '2025-01-31' }, + notificationTime: 5, + }; + + // When: 1월 15일만 단일 수정 + const singleEditEvent = createSingleEditEvent(dailyRepeatEvent, '2025-01-15'); + + // Then: 단일 수정된 일정이 생성되어야 함 + expect(singleEditEvent.repeat.type).toBe('none'); + expect(singleEditEvent.date).toBe('2025-01-15'); + expect(singleEditEvent.title).toBe('아침 운동'); + }); + + it('매월 반복 일정의 특정 달을 단일 수정할 수 있다', () => { + // Given: 매월 반복 일정 + const monthlyRepeatEvent: Event = { + id: 'monthly-1', + title: '월말 정리', + date: '2025-01-31', + startTime: '18:00', + endTime: '19:00', + description: '매월 말 정리 작업', + location: '사무실', + category: '업무', + repeat: { type: 'monthly', interval: 1, endDate: '2025-12-31' }, + notificationTime: 30, + }; + + // When: 3월 31일만 단일 수정 + const singleEditEvent = createSingleEditEvent(monthlyRepeatEvent, '2025-03-31'); + + // Then: 단일 수정된 일정이 생성되어야 함 + expect(singleEditEvent.repeat.type).toBe('none'); + expect(singleEditEvent.date).toBe('2025-03-31'); + expect(singleEditEvent.title).toBe('월말 정리'); + }); + }); +}); From 280a8b6cda4796f3f09a41f401b3e996bd54a6ed Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 05:55:50 +0900 Subject: [PATCH 25/34] =?UTF-8?q?feat:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A8=EC=9D=BC=20=EC=88=98=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=99=84=EC=84=B1=20-=20isRep?= =?UTF-8?q?eatEvent=20=ED=95=A8=EC=88=98=EB=A1=9C=20=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?-=20createSingleEditEvent=20=ED=95=A8=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=EC=88=98=EC=A0=95=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1=20-=20=EA=B3=A0=EC=9C=A0=20ID=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=B0=98=EB=B3=B5=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A0=9C=EA=B1=B0=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20=EB=A7=A4=EC=9D=BC/=EB=A7=A4=EC=A3=BC/=EB=A7=A4?= =?UTF-8?q?=EC=9B=94/=EB=A7=A4=EB=85=84=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A8=EC=9D=BC=20=EC=88=98=EC=A0=95=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/repeatEventUtils.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/utils/repeatEventUtils.ts b/src/utils/repeatEventUtils.ts index 87acd33d..0ae14ec8 100644 --- a/src/utils/repeatEventUtils.ts +++ b/src/utils/repeatEventUtils.ts @@ -1,4 +1,4 @@ -import { RepeatInfo } from '../types'; +import { RepeatInfo, Event } from '../types'; interface RepeatEventInput { date: string; @@ -144,3 +144,22 @@ const formatDate = (date: Date): string => { const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; + +// 반복 일정인지 확인하는 함수 +export const isRepeatEvent = (event: Event): boolean => { + return event.repeat.type !== 'none'; +}; + +// 반복 일정을 단일 수정 이벤트로 생성하는 함수 +export const createSingleEditEvent = (originalEvent: Event, targetDate: string): Event => { + return { + ...originalEvent, + id: `${originalEvent.id}-single-edit-${targetDate}`, + date: targetDate, + repeat: { + type: 'none', + interval: 1, + endDate: undefined, + }, + }; +}; From eaf31dbcbdbcfd2e510ed8788e7b4f786f1ca0fb Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 05:59:41 +0900 Subject: [PATCH 26/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A8=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/repeatEventSingleDelete.spec.ts | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 src/__tests__/unit/repeatEventSingleDelete.spec.ts diff --git a/src/__tests__/unit/repeatEventSingleDelete.spec.ts b/src/__tests__/unit/repeatEventSingleDelete.spec.ts new file mode 100644 index 00000000..17fbfd87 --- /dev/null +++ b/src/__tests__/unit/repeatEventSingleDelete.spec.ts @@ -0,0 +1,292 @@ +import { describe, it, expect } from 'vitest'; + +import { Event } from '../../types'; +import { deleteSingleRepeatEvent, isRepeatEvent } from '../../utils/repeatEventUtils'; + +describe('반복 일정 단일 삭제 기능', () => { + describe('단일 삭제 시 특정 일정만 삭제', () => { + it('반복 일정 중 특정 일정만 삭제할 수 있다', () => { + // Given: 매주 반복하는 일정들 + const repeatEvents: Event[] = [ + { + id: '1-1', + title: '팀 미팅', + date: '2025-01-06', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + { + id: '1-2', + title: '팀 미팅', + date: '2025-01-13', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + { + id: '1-3', + title: '팀 미팅', + date: '2025-01-20', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + ]; + + // When: 두 번째 일정만 삭제 + const remainingEvents = deleteSingleRepeatEvent(repeatEvents, '1-2'); + + // Then: 삭제된 일정을 제외한 나머지만 남아야 함 + expect(remainingEvents).toHaveLength(2); + expect(remainingEvents[0].id).toBe('1-1'); + expect(remainingEvents[1].id).toBe('1-3'); + expect(remainingEvents.find((event) => event.id === '1-2')).toBeUndefined(); + }); + + it('삭제된 일정은 반복 일정 목록에서 완전히 제거된다', () => { + // Given: 반복 일정들 + const repeatEvents: Event[] = [ + { + id: 'daily-1', + title: '아침 운동', + date: '2025-01-01', + startTime: '06:00', + endTime: '07:00', + description: '매일 아침 운동', + location: '집', + category: '개인', + repeat: { type: 'daily', interval: 1, endDate: '2025-01-31' }, + notificationTime: 5, + }, + { + id: 'daily-2', + title: '아침 운동', + date: '2025-01-02', + startTime: '06:00', + endTime: '07:00', + description: '매일 아침 운동', + location: '집', + category: '개인', + repeat: { type: 'daily', interval: 1, endDate: '2025-01-31' }, + notificationTime: 5, + }, + ]; + + // When: 첫 번째 일정 삭제 + const remainingEvents = deleteSingleRepeatEvent(repeatEvents, 'daily-1'); + + // Then: 첫 번째 일정이 완전히 제거되어야 함 + expect(remainingEvents).toHaveLength(1); + expect(remainingEvents[0].id).toBe('daily-2'); + expect(remainingEvents.find((event) => event.id === 'daily-1')).toBeUndefined(); + }); + + it('존재하지 않는 ID로 삭제 시도하면 원본 목록이 그대로 반환된다', () => { + // Given: 반복 일정들 + const repeatEvents: Event[] = [ + { + id: '1-1', + title: '팀 미팅', + date: '2025-01-06', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + ]; + + // When: 존재하지 않는 ID로 삭제 시도 + const remainingEvents = deleteSingleRepeatEvent(repeatEvents, 'non-existent-id'); + + // Then: 원본 목록이 그대로 반환되어야 함 + expect(remainingEvents).toHaveLength(1); + expect(remainingEvents[0].id).toBe('1-1'); + expect(remainingEvents).toEqual(repeatEvents); + }); + }); + + describe('단일 삭제 후 반복 일정 상태', () => { + it('삭제 후 남은 일정들은 여전히 반복 일정으로 유지된다', () => { + // Given: 매주 반복하는 일정들 + const repeatEvents: Event[] = [ + { + id: 'weekly-1', + title: '팀 미팅', + date: '2025-01-06', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + { + id: 'weekly-2', + title: '팀 미팅', + date: '2025-01-13', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'weekly', interval: 1, endDate: '2025-01-29' }, + notificationTime: 10, + }, + ]; + + // When: 첫 번째 일정 삭제 + const remainingEvents = deleteSingleRepeatEvent(repeatEvents, 'weekly-1'); + + // Then: 남은 일정은 여전히 반복 일정이어야 함 + expect(remainingEvents).toHaveLength(1); + expect(isRepeatEvent(remainingEvents[0])).toBe(true); + expect(remainingEvents[0].repeat.type).toBe('weekly'); + }); + + it('모든 반복 일정을 삭제하면 빈 배열이 반환된다', () => { + // Given: 반복 일정들 + const repeatEvents: Event[] = [ + { + id: 'single-1', + title: '일회성 미팅', + date: '2025-01-06', + startTime: '09:00', + endTime: '10:00', + description: '일회성 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1, endDate: undefined }, + notificationTime: 10, + }, + ]; + + // When: 유일한 일정 삭제 + const remainingEvents = deleteSingleRepeatEvent(repeatEvents, 'single-1'); + + // Then: 빈 배열이 반환되어야 함 + expect(remainingEvents).toHaveLength(0); + expect(remainingEvents).toEqual([]); + }); + }); + + describe('단일 삭제 시나리오', () => { + it('매일 반복 일정의 특정 날짜를 삭제할 수 있다', () => { + // Given: 매일 반복 일정들 + const dailyRepeatEvents: Event[] = [ + { + id: 'daily-1', + title: '아침 운동', + date: '2025-01-01', + startTime: '06:00', + endTime: '07:00', + description: '매일 아침 운동', + location: '집', + category: '개인', + repeat: { type: 'daily', interval: 1, endDate: '2025-01-05' }, + notificationTime: 5, + }, + { + id: 'daily-2', + title: '아침 운동', + date: '2025-01-02', + startTime: '06:00', + endTime: '07:00', + description: '매일 아침 운동', + location: '집', + category: '개인', + repeat: { type: 'daily', interval: 1, endDate: '2025-01-05' }, + notificationTime: 5, + }, + { + id: 'daily-3', + title: '아침 운동', + date: '2025-01-03', + startTime: '06:00', + endTime: '07:00', + description: '매일 아침 운동', + location: '집', + category: '개인', + repeat: { type: 'daily', interval: 1, endDate: '2025-01-05' }, + notificationTime: 5, + }, + ]; + + // When: 1월 2일 일정 삭제 + const remainingEvents = deleteSingleRepeatEvent(dailyRepeatEvents, 'daily-2'); + + // Then: 1월 2일을 제외한 나머지 일정들이 남아야 함 + expect(remainingEvents).toHaveLength(2); + expect(remainingEvents[0].date).toBe('2025-01-01'); + expect(remainingEvents[1].date).toBe('2025-01-03'); + expect(remainingEvents.find((event) => event.date === '2025-01-02')).toBeUndefined(); + }); + + it('매월 반복 일정의 특정 달을 삭제할 수 있다', () => { + // Given: 매월 반복 일정들 + const monthlyRepeatEvents: Event[] = [ + { + id: 'monthly-1', + title: '월말 정리', + date: '2025-01-31', + startTime: '18:00', + endTime: '19:00', + description: '매월 말 정리 작업', + location: '사무실', + category: '업무', + repeat: { type: 'monthly', interval: 1, endDate: '2025-03-31' }, + notificationTime: 30, + }, + { + id: 'monthly-2', + title: '월말 정리', + date: '2025-02-28', + startTime: '18:00', + endTime: '19:00', + description: '매월 말 정리 작업', + location: '사무실', + category: '업무', + repeat: { type: 'monthly', interval: 1, endDate: '2025-03-31' }, + notificationTime: 30, + }, + { + id: 'monthly-3', + title: '월말 정리', + date: '2025-03-31', + startTime: '18:00', + endTime: '19:00', + description: '매월 말 정리 작업', + location: '사무실', + category: '업무', + repeat: { type: 'monthly', interval: 1, endDate: '2025-03-31' }, + notificationTime: 30, + }, + ]; + + // When: 2월 28일 일정 삭제 + const remainingEvents = deleteSingleRepeatEvent(monthlyRepeatEvents, 'monthly-2'); + + // Then: 2월 28일을 제외한 나머지 일정들이 남아야 함 + expect(remainingEvents).toHaveLength(2); + expect(remainingEvents[0].date).toBe('2025-01-31'); + expect(remainingEvents[1].date).toBe('2025-03-31'); + expect(remainingEvents.find((event) => event.date === '2025-02-28')).toBeUndefined(); + }); + }); +}); From e851e9595e378a37991767d8f2e792250211e483 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 06:00:15 +0900 Subject: [PATCH 27/34] =?UTF-8?q?feat:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=A4=91=20=ED=8A=B9=EC=A0=95=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=EB=A7=8C=20=EC=82=AD=EC=A0=9C=ED=95=98=EB=8A=94=20=ED=95=A8?= =?UTF-8?q?=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/repeatEventUtils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/utils/repeatEventUtils.ts b/src/utils/repeatEventUtils.ts index 0ae14ec8..6a01c044 100644 --- a/src/utils/repeatEventUtils.ts +++ b/src/utils/repeatEventUtils.ts @@ -163,3 +163,8 @@ export const createSingleEditEvent = (originalEvent: Event, targetDate: string): }, }; }; + +// 반복 일정 중 특정 일정만 삭제하는 함수 +export const deleteSingleRepeatEvent = (events: Event[], eventIdToDelete: string): Event[] => { + return events.filter((event) => event.id !== eventIdToDelete); +}; From d6648c56a5d17c4cfce5d5b7dfb641fac7a27cd9 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 06:04:04 +0900 Subject: [PATCH 28/34] =?UTF-8?q?fix:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95=20-=2031=EC=9D=BC=EC=97=90=20?= =?UTF-8?q?=EB=A7=A4=EC=9B=94=20=EB=B0=98=EB=B3=B5=20=EC=8B=9C=2031?= =?UTF-8?q?=EC=9D=BC=EC=9D=B4=20=EC=97=86=EB=8A=94=20=EB=8B=AC=EC=9D=80=20?= =?UTF-8?q?=EA=B1=B4=EB=84=88=EB=9B=B0=EA=B8=B0=20-=202=EC=9B=94=2029?= =?UTF-8?q?=EC=9D=BC=EC=97=90=20=EB=A7=A4=EB=85=84=20=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9C=A4=EB=85=84=EC=9D=B4=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=ED=95=B4=EB=8A=94=20=EA=B1=B4=EB=84=88=EB=9B=B0=EA=B8=B0=20-?= =?UTF-8?q?=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD:=20'31=EC=9D=BC?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EC=83=9D=EC=84=B1',=20'29=EC=9D=BC?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EC=83=9D=EC=84=B1'=20=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/repeatEventUtils.ts | 38 +++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/utils/repeatEventUtils.ts b/src/utils/repeatEventUtils.ts index 6a01c044..9fcaffe4 100644 --- a/src/utils/repeatEventUtils.ts +++ b/src/utils/repeatEventUtils.ts @@ -79,11 +79,28 @@ const generateMonthlyRepeatDates = (event: RepeatEventInput): string[] => { let nextYear = currentDate.getFullYear() + Math.floor(nextMonth / 12); nextMonth = nextMonth % 12; - // 원래 날짜가 다음 달에 존재하지 않는 경우 해당 달의 마지막 날로 설정 + // 원래 날짜가 다음 달에 존재하지 않는 경우 해당 달을 건너뛰기 const lastDayOfNextMonth = getLastDayOfMonth(nextYear, nextMonth); - const nextDay = Math.min(originalDay, lastDayOfNextMonth); - const nextDate = new Date(nextYear, nextMonth, nextDay); + if (originalDay > lastDayOfNextMonth) { + // 해당 달을 건너뛰고 다음 달의 같은 날짜로 설정 + nextMonth = nextMonth + 1; + if (nextMonth >= 12) { + nextMonth = 0; + nextYear += 1; + } + + // 다음 달도 원래 날짜가 없으면 계속 건너뛰기 + while (getLastDayOfMonth(nextYear, nextMonth) < originalDay) { + nextMonth += 1; + if (nextMonth >= 12) { + nextMonth = 0; + nextYear += 1; + } + } + } + + const nextDate = new Date(nextYear, nextMonth, originalDay); // endDate를 초과하면 종료 if (nextDate > endDate) { @@ -109,11 +126,20 @@ const generateYearlyRepeatDates = (event: RepeatEventInput): string[] => { // 다음 해 계산 let nextYear = currentDate.getFullYear() + event.interval; - // 원래 날짜가 다음 해에 존재하지 않는 경우 해당 해의 마지막 날로 설정 + // 원래 날짜가 다음 해에 존재하지 않는 경우 해당 해를 건너뛰기 const lastDayOfNextMonth = getLastDayOfMonth(nextYear, originalMonth); - const nextDay = Math.min(originalDay, lastDayOfNextMonth); - const nextDate = new Date(nextYear, originalMonth, nextDay); + if (originalDay > lastDayOfNextMonth) { + // 해당 해를 건너뛰고 다음 해의 같은 날짜로 설정 + nextYear += 1; + + // 다음 해도 원래 날짜가 없으면 계속 건너뛰기 + while (getLastDayOfMonth(nextYear, originalMonth) < originalDay) { + nextYear += 1; + } + } + + const nextDate = new Date(nextYear, originalMonth, originalDay); // endDate를 초과하면 종료 if (nextDate > endDate) { From fce10a93e043e846d4c6e6c352c07e3c8769a383 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 06:09:38 +0900 Subject: [PATCH 29/34] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/repeatEventGeneration.spec.ts | 24 +++++++++---------- .../unit/repeatEventSelection.spec.ts | 1 + 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/__tests__/unit/repeatEventGeneration.spec.ts b/src/__tests__/unit/repeatEventGeneration.spec.ts index bd9aea35..db261702 100644 --- a/src/__tests__/unit/repeatEventGeneration.spec.ts +++ b/src/__tests__/unit/repeatEventGeneration.spec.ts @@ -55,19 +55,19 @@ describe('반복 일정 생성 및 관리 기능', () => { date: '2025-01-31', repeatType: 'monthly' as const, interval: 1, - endDate: '2025-05-31', + endDate: '2025-08-31', }; // When: 반복 날짜 생성 const repeatDates = generateRepeatDates(eventData); - // Then: 5개월간의 반복 날짜가 생성되어야 함 + // Then: 31일이 있는 달에만 생성되어야 함 (2월, 4월, 6월은 건너뛰기) expect(repeatDates).toHaveLength(5); expect(repeatDates[0]).toBe('2025-01-31'); - expect(repeatDates[1]).toBe('2025-02-28'); // 2월은 28일까지만 - expect(repeatDates[2]).toBe('2025-03-31'); - expect(repeatDates[3]).toBe('2025-04-30'); // 4월은 30일까지만 - expect(repeatDates[4]).toBe('2025-05-31'); + expect(repeatDates[1]).toBe('2025-03-31'); // 2월 건너뛰기 + expect(repeatDates[2]).toBe('2025-05-31'); // 4월 건너뛰기 + expect(repeatDates[3]).toBe('2025-07-31'); // 6월 건너뛰기 + expect(repeatDates[4]).toBe('2025-08-31'); }); it('매년 반복 일정을 생성할 수 있다', () => { @@ -82,13 +82,11 @@ describe('반복 일정 생성 및 관리 기능', () => { // When: 반복 날짜 생성 const repeatDates = generateRepeatDates(eventData); - // Then: 윤년에는 2월 29일, 윤년이 아닌 해에는 2월 28일에 생성되어야 함 - expect(repeatDates).toHaveLength(5); - expect(repeatDates[0]).toBe('2024-02-29'); // 윤년 - expect(repeatDates[1]).toBe('2025-02-28'); // 윤년 아님 - expect(repeatDates[2]).toBe('2026-02-28'); // 윤년 아님 - expect(repeatDates[3]).toBe('2027-02-28'); // 윤년 아님 - expect(repeatDates[4]).toBe('2028-02-29'); // 윤년 + // Then: 2월 29일이 있는 해(윤년)에만 생성되어야 함 + expect(repeatDates).toHaveLength(2); + expect(repeatDates[0]).toBe('2024-02-29'); // 2024년 (윤년) + expect(repeatDates[1]).toBe('2028-02-29'); // 2028년 (윤년) + // 2025, 2026, 2027년은 2월 29일이 없으므로 건너뛰기 }); }); diff --git a/src/__tests__/unit/repeatEventSelection.spec.ts b/src/__tests__/unit/repeatEventSelection.spec.ts index 97e5128e..875006d8 100644 --- a/src/__tests__/unit/repeatEventSelection.spec.ts +++ b/src/__tests__/unit/repeatEventSelection.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; + import { RepeatType } from '../../types'; describe('반복 유형 선택 기능', () => { From d4cc9856f79518606d59114757ffa7726ee85d46 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 06:14:37 +0900 Subject: [PATCH 30/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=ED=86=B5=ED=95=A9=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 788dae14..e18d90f4 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -340,3 +340,50 @@ it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트 expect(screen.getByText('10분 후 기존 회의 일정이 시작됩니다.')).toBeInTheDocument(); }); + +describe('반복 일정 통합 기능', () => { + it('반복 일정 체크박스를 클릭하면 반복 설정 UI가 표시된다', async () => { + const { user } = setup(); + + // 반복 일정 체크박스 클릭 + await user.click(screen.getByLabelText('반복 일정')); + + // 반복 유형 선택 UI가 표시되어야 함 + expect(screen.getByLabelText('반복 유형')).toBeInTheDocument(); + expect(screen.getByLabelText('반복 간격')).toBeInTheDocument(); + expect(screen.getByLabelText('반복 종료일')).toBeInTheDocument(); + }); + + it('매일 반복 일정을 생성할 수 있다', async () => { + setupMockHandlerCreation(); + + const { user } = setup(); + + // 반복 일정 생성 + await user.click(screen.getAllByText('일정 추가')[0]); + + await user.type(screen.getByLabelText('제목'), '매일 운동'); + await user.type(screen.getByLabelText('날짜'), '2025-01-01'); + await user.type(screen.getByLabelText('시작 시간'), '06:00'); + await user.type(screen.getByLabelText('종료 시간'), '07:00'); + await user.type(screen.getByLabelText('설명'), '매일 아침 운동'); + await user.type(screen.getByLabelText('위치'), '집'); + await user.click(screen.getByLabelText('카테고리')); + await user.click(within(screen.getByLabelText('카테고리')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: '개인-option' })); + + // 반복 설정 + await user.click(screen.getByLabelText('반복 일정')); + await user.click(screen.getByLabelText('반복 유형')); + await user.click(screen.getByRole('option', { name: '매일' })); + await user.type(screen.getByLabelText('반복 종료일'), '2025-01-05'); + + await user.click(screen.getByTestId('event-submit-button')); + + // 이벤트 리스트에서 반복 정보 확인 + const eventList = within(screen.getByTestId('event-list')); + expect(eventList.getByText('매일 운동')).toBeInTheDocument(); + expect(eventList.getByText(/반복: 매일/)).toBeInTheDocument(); + expect(eventList.getByText(/종료: 2025-01-05/)).toBeInTheDocument(); + }); +}); From 16c2f0a7aba22599b4442eaaec012c8d4469b38e Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 06:18:47 +0900 Subject: [PATCH 31/34] =?UTF-8?q?test:=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=ED=86=B5=ED=95=A9=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index e18d90f4..a1b7180a 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -342,6 +342,33 @@ it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트 }); describe('반복 일정 통합 기능', () => { + it('반복 일정 체크박스가 존재한다', async () => { + setup(); + + // 반복 일정 체크박스가 존재해야 함 + expect(screen.getByLabelText('반복 일정')).toBeInTheDocument(); + }); + + it('반복 일정 체크박스 클릭 후 상태를 확인한다', async () => { + const { user } = setup(); + + // 반복 일정 체크박스 클릭 + const repeatCheckbox = screen.getByLabelText('반복 일정'); + expect(repeatCheckbox).toBeInTheDocument(); + + // 클릭 전 상태 확인 + expect(repeatCheckbox).not.toBeChecked(); + + // 클릭 + await user.click(repeatCheckbox); + + // 클릭 후 상태 확인 + expect(repeatCheckbox).toBeChecked(); + + // 현재 DOM 상태 출력 (디버깅용) + console.log('Current DOM after checkbox click:', document.body.innerHTML); + }); + it('반복 일정 체크박스를 클릭하면 반복 설정 UI가 표시된다', async () => { const { user } = setup(); From 82a950fd134890f01f0ca1b7120e1e0d455d7c26 Mon Sep 17 00:00:00 2001 From: xxziiko Date: Fri, 29 Aug 2025 06:27:24 +0900 Subject: [PATCH 32/34] =?UTF-8?q?refactor:=20set=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 27 ++++++++++++++------------- src/hooks/useEventForm.ts | 4 +++- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 089eaaee..a9ec8294 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,7 +36,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 } from './types'; +import { Event, EventForm, RepeatType } from './types'; import { formatDate, formatMonth, @@ -46,6 +46,7 @@ import { getWeeksAtMonth, } from './utils/dateUtils'; import { findOverlappingEvents } from './utils/eventOverlap'; +import { getRepeatDisplayInfo } from './utils/eventStateUtils'; import { getTimeErrorMessage } from './utils/timeValidation'; const categories = ['업무', '개인', '가족', '기타']; @@ -77,8 +78,11 @@ function App() { isRepeating, setIsRepeating, repeatType, + setRepeatType, repeatInterval, + setRepeatInterval, repeatEndDate, + setRepeatEndDate, notificationTime, setNotificationTime, startTimeError, @@ -444,12 +448,12 @@ function App() { - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( + {isRepeating && ( - 반복 유형 + 반복 유형