From 9eb17b02e991f6a9e8449e09c30e31caab978305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Tue, 19 Aug 2025 03:17:22 +0900 Subject: [PATCH 01/27] =?UTF-8?q?feat:=20msw=20=ED=95=B8=EB=93=A4=EB=9F=AC?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__mocks__/handlers.ts | 40 +++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/__mocks__/handlers.ts b/src/__mocks__/handlers.ts index 42d6d4b7..f09d41f1 100644 --- a/src/__mocks__/handlers.ts +++ b/src/__mocks__/handlers.ts @@ -1,16 +1,48 @@ +import { randomUUID } from 'crypto'; + import { http, HttpResponse } from 'msw'; import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; import { Event } from '../types'; +const eventsData = [...events]; + // ! HARD // ! 각 응답에 대한 MSW 핸들러를 작성해주세요. GET 요청은 이미 작성되어 있는 events json을 활용해주세요. export const handlers = [ - http.get('/api/events', () => {}), + http.get('/api/events', () => { + return HttpResponse.json({ events: eventsData }); + }), + + http.post('/api/events', async ({ request }) => { + const newEvent = (await request.json()) as Omit; + const createdEvent = { id: randomUUID(), ...newEvent } as Event; + eventsData.push(createdEvent); + return HttpResponse.json(createdEvent, { status: 201 }); + }), + + http.put('/api/events/:id', async ({ params, request }) => { + const { id } = params; + const updateEvent = (await request.json()) as Partial; + + const eventIndex = eventsData.findIndex((event) => event.id === id); + if (eventIndex === -1) { + return HttpResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + eventsData[eventIndex] = { ...eventsData[eventIndex], ...updateEvent }; + return HttpResponse.json(eventsData[eventIndex]); + }), - http.post('/api/events', async ({ request }) => {}), + http.delete('/api/events/:id', ({ params }) => { + const { id } = params; - http.put('/api/events/:id', async ({ params, request }) => {}), + const eventIndex = eventsData.findIndex((event) => event.id === id); + if (eventIndex === -1) { + return HttpResponse.json({ error: 'Event not found' }, { status: 404 }); + } - http.delete('/api/events/:id', ({ params }) => {}), + eventsData.splice(eventIndex, 1); + return HttpResponse.json(null, { status: 204 }); + }), ]; From 3fe5dc9a2a17171a413c68052751eb9d7d092ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Tue, 19 Aug 2025 03:17:42 +0900 Subject: [PATCH 02/27] =?UTF-8?q?feat:=20msw=20=ED=95=B8=EB=93=A4=EB=9F=AC?= =?UTF-8?q?=20=EC=9C=A0=ED=8B=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__mocks__/handlersUtils.ts | 70 ++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index 405837ec..ee8ccd13 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -1,10 +1,74 @@ +import { randomUUID } from 'crypto'; + +import { http, HttpResponse } from 'msw'; + +import { server } from '../setupTests'; import { Event } from '../types'; // ! Hard // ! 이벤트는 생성, 수정 되면 fetch를 다시 해 상태를 업데이트 합니다. 이를 위한 제어가 필요할 것 같은데요. 어떻게 작성해야 테스트가 병렬로 돌아도 안정적이게 동작할까요? // ! 아래 이름을 사용하지 않아도 되니, 독립적이게 테스트를 구동할 수 있는 방법을 찾아보세요. 그리고 이 로직을 PR에 설명해주세요. -export const setupMockHandlerCreation = (initEvents = [] as Event[]) => {}; +export const setupMockHandlerCreation = (initEvents = [] as Event[]) => { + const eventsData = [...initEvents]; + + const newHandlers = [ + http.get('/api/events', () => { + return HttpResponse.json({ events: eventsData }); + }), + http.post('/api/events', async ({ request }) => { + const newEvent = (await request.json()) as Omit; + const createdEvent = { id: randomUUID(), ...newEvent } as Event; + eventsData.push(createdEvent); + return HttpResponse.json(createdEvent, { status: 201 }); + }), + ]; + + server.use(...newHandlers); +}; + +export const setupMockHandlerUpdating = (initEvents = [] as Event[]) => { + const eventsData = [...initEvents]; + + const newHandlers = [ + http.get('/api/events', () => { + return HttpResponse.json({ events: eventsData }); + }), + http.put('/api/events/:id', async ({ params, request }) => { + const { id } = params; + const updateEvent = (await request.json()) as Partial; + + const eventIndex = eventsData.findIndex((event) => event.id === id); + if (eventIndex === -1) { + return HttpResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + eventsData[eventIndex] = { ...eventsData[eventIndex], ...updateEvent }; + return HttpResponse.json(eventsData[eventIndex]); + }), + ]; + + server.use(...newHandlers); +}; + +export const setupMockHandlerDeletion = (initEvents = [] as Event[]) => { + const eventsData = [...initEvents]; + + const newHandlers = [ + http.get('/api/events', () => { + return HttpResponse.json({ events: eventsData }); + }), + http.delete('/api/events/:id', ({ params }) => { + const { id } = params; + + const eventIndex = eventsData.findIndex((event) => event.id === id); + if (eventIndex === -1) { + return HttpResponse.json({ error: 'Event not found' }, { status: 404 }); + } -export const setupMockHandlerUpdating = () => {}; + eventsData.splice(eventIndex, 1); + return HttpResponse.json(null, { status: 204 }); + }), + ]; -export const setupMockHandlerDeletion = () => {}; + server.use(...newHandlers); +}; From 1119ed5772258ac0b9c140c4d516b4052af42ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Wed, 20 Aug 2025 17:37:05 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20dateUtils=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=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 --- src/__tests__/unit/easy.dateUtils.spec.ts | 313 ++++++++++++++++++---- src/utils/dateUtils.ts | 3 + 2 files changed, 269 insertions(+), 47 deletions(-) diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..8d5c56fb 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -12,105 +12,324 @@ import { } from '../../utils/dateUtils'; describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); + it('1월은 31일 수를 반환한다', () => { + expect(getDaysInMonth(2025, 1)).toBe(31); + }); + + it('4월은 30일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 4)).toBe(30); + }); + + it('윤년의 2월에 대해 29일을 반환한다', () => { + expect(getDaysInMonth(2024, 2)).toBe(29); + }); + + it('평년의 2월에 대해 28일을 반환한다', () => { + expect(getDaysInMonth(2025, 2)).toBe(28); + }); + + it('유효하지 않은 월에 대해 적절히 처리한다', () => { + expect(() => getDaysInMonth(2024, 13)).toThrow(); + expect(() => getDaysInMonth(2024, 0)).toThrow(); + expect(() => getDaysInMonth(2024, -1)).toThrow(); + }); +}); - it('4월은 30일 일수를 반환한다', () => {}); +describe('getWeekDates', () => { + const expectWeekFrom = (actualWeek: Date[], startDay: Date) => { + expect(actualWeek).toHaveLength(7); - it('윤년의 2월에 대해 29일을 반환한다', () => {}); + actualWeek.forEach((date, index) => { + const expectedDate = new Date(startDay); + expectedDate.setDate(startDay.getDate() + index); + expect(date).toEqual(expectedDate); + }); + }; - it('평년의 2월에 대해 28일을 반환한다', () => {}); + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const wednesday = new Date(2025, 7, 20); + const result = getWeekDates(wednesday); - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); -}); + expectWeekFrom(result, new Date(2025, 7, 17)); + }); -describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const monday = new Date(2025, 7, 18); + const result = getWeekDates(monday); + + expectWeekFrom(result, new Date(2025, 7, 17)); + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const sunday = new Date(2025, 7, 24); + const result = getWeekDates(sunday); + + expectWeekFrom(result, new Date(2025, 7, 24)); + }); + + it('12월 말일이 포함된 주가 다음 해까지 올바르게 처리된다 (연말)', () => { + const december31 = new Date(2025, 11, 31); + const result = getWeekDates(december31); + + expectWeekFrom(result, new Date(2025, 11, 28)); + }); - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + it('1월 초가 포함된 주가 이전 해부터 올바르게 처리된다 (연초)', () => { + const january1 = new Date(2026, 0, 1); + const result = getWeekDates(january1); - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + expectWeekFrom(result, new Date(2025, 11, 28)); + }); - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + const february29 = new Date(2024, 1, 29); + const result = getWeekDates(february29); - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); + expectWeekFrom(result, new Date(2024, 1, 25)); + }); - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); + it('1월 31일을 포함한 주는 2월 1일까지 포함하여 반환한다', () => { + const lastDayOfMonth = new Date(2025, 0, 31); + const result = getWeekDates(lastDayOfMonth); - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); + expectWeekFrom(result, new Date(2025, 0, 26)); + }); }); describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); + it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => { + const july1 = new Date(2025, 6, 1); + const result = getWeeksAtMonth(july1); + + expect(result).toEqual([ + [null, null, 1, 2, 3, 4, 5], + [6, 7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18, 19], + [20, 21, 22, 23, 24, 25, 26], + [27, 28, 29, 30, 31, null, null], + ]); + }); }); describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); + const events: Event[] = [ + { + id: '1', + title: 'Event 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 0, + }, + { + id: '2', + title: 'Event 2', + date: '2025-07-02', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 0, + }, + ]; + + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + const result = getEventsForDay(events, 1); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Event 1'); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + const result = getEventsForDay(events, 3); + + expect(result).toEqual([]); + }); + + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + const result = getEventsForDay(events, 0); + + expect(result).toEqual([]); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + const result = getEventsForDay(events, 32); + + expect(result).toEqual([]); + }); +}); - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); +describe('formatWeek', () => { + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date(2025, 7, 20)); - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); + expect(result).toBe('2025년 8월 3주'); + }); - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); -}); + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date(2025, 7, 3)); -describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); + expect(result).toBe('2025년 8월 1주'); + }); + + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date(2025, 7, 30)); - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); + expect(result).toBe('2025년 8월 4주'); + }); - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date(2025, 11, 31)); - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); + expect(result).toBe('2026년 1월 1주'); + }); - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date(2024, 1, 29)); - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + expect(result).toBe('2024년 2월 5주'); + }); + + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date(2025, 1, 28)); + + expect(result).toBe('2025년 2월 4주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + const result = formatMonth(new Date(2025, 6, 10)); + + expect(result).toBe('2025년 7월'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); + describe('2025년 7월 범위(7/1 ~ 7/31)에서', () => { + const startDate = new Date(2025, 6, 1); + const endDate = new Date(2025, 6, 31); + + it('범위 내 날짜는 true를 반환한다', () => { + const result = isDateInRange(new Date(2025, 6, 10), startDate, endDate); - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); + expect(result).toBe(true); + }); - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); + it('시작일은 true를 반환한다', () => { + const result = isDateInRange(startDate, startDate, endDate); - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); + expect(result).toBe(true); + }); - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); + it('종료일은 true를 반환한다', () => { + const result = isDateInRange(endDate, startDate, endDate); - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + expect(result).toBe(true); + }); + + it('범위 이전 날짜는 false를 반환한다', () => { + const result = isDateInRange(new Date(2025, 5, 30), startDate, endDate); + + expect(result).toBe(false); + }); + }); + + it('시작일이 종료일보다 늦으면 false를 반환한다', () => { + const result = isDateInRange( + new Date(2025, 6, 10), + new Date(2025, 6, 31), + new Date(2025, 6, 1) + ); + + expect(result).toBe(false); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + const result = fillZero(5); + + expect(result).toBe('05'); + }); + + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + const result = fillZero(10); + + expect(result).toBe('10'); + }); + + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + const result = fillZero(3, 3); + + expect(result).toBe('003'); + }); - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + const result = fillZero(100); - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); + expect(result).toBe('100'); + }); - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + const result = fillZero(0); - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); + expect(result).toBe('00'); + }); - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + const result = fillZero(1, 5); - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); + expect(result).toBe('00001'); + }); - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + const result = fillZero(3.14, 5); - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); + expect(result).toBe('03.14'); + }); + + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + const result = fillZero(1); + + expect(result).toBe('01'); + }); + + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + const result = fillZero(1000, 2); + + expect(result).toBe('1000'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + const result = formatDate(new Date(2025, 7, 20)); + + expect(result).toBe('2025-08-20'); + }); + + it('day 파라미터로 다른 일자를 지정할 수 있다', () => { + const result = formatDate(new Date(2025, 7, 20), 21); + + expect(result).toBe('2025-08-21'); + }); + + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const result = formatDate(new Date(2025, 0, 20)); - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); + expect(result).toBe('2025-01-20'); + }); - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const result = formatDate(new Date(2025, 7, 1)); - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + expect(result).toBe('2025-08-01'); + }); }); diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index be78512c..8f3457a3 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -4,6 +4,9 @@ import { Event } from '../types.ts'; * 주어진 년도와 월의 일수를 반환합니다. */ export function getDaysInMonth(year: number, month: number): number { + if (month < 1 || month > 12) { + throw new Error('월은 1부터 12 사이의 값이어야 합니다.'); + } return new Date(year, month, 0).getDate(); } From bb24a8e50bd9ff0d4331fb31dc4751c4522b8f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 02:25:55 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=A8=20=EA=B2=80=EC=82=AC=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=93=A4=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=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 --- src/__tests__/unit/easy.eventOverlap.spec.ts | 115 +++++++++++++++++-- 1 file changed, 104 insertions(+), 11 deletions(-) diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..f538546c 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -6,31 +6,124 @@ import { parseDateTime, } from '../../utils/eventOverlap'; describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + const result = parseDateTime('2025-07-01', '14:30'); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + expect(result).toBeInstanceOf(Date); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + const result = parseDateTime('2025-13-01', '14:30'); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + expect(isNaN(result.getTime())).toBe(true); + }); + + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + const result = parseDateTime('2025-07-01', '25:30'); + + expect(isNaN(result.getTime())).toBe(true); + }); + + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + const result = parseDateTime('', '14:30'); + + expect(isNaN(result.getTime())).toBe(true); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + const event = { + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + } as Event; + + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const result = convertEventToDateRange(event); - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + expect(result.start.getTime()).toBe(new Date('2025-07-01T09:00').getTime()); + expect(result.end.getTime()).toBe(new Date('2025-07-01T10:00').getTime()); + }); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const result = convertEventToDateRange({ + ...event, + date: '2025-13-01', + }); + + expect(isNaN(result.start.getTime())).toBe(true); + expect(isNaN(result.end.getTime())).toBe(true); + }); + + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const result = convertEventToDateRange({ + ...event, + startTime: '25:00', + }); + + expect(isNaN(result.start.getTime())).toBe(true); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + const meeting1 = { + date: '2025-07-01', + startTime: '09:00', + endTime: '12:00', + } as Event; + + const meeting2 = { + date: '2025-07-01', + startTime: '10:00', + endTime: '11:30', + } as Event; - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + const result = isOverlapping(meeting1, meeting2); + + expect(result).toBe(true); + }); + + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + const meeting1 = { + date: '2025-07-01', + startTime: '09:00', + endTime: '12:00', + } as Event; + + const meeting2 = { + date: '2025-07-01', + startTime: '12:00', + endTime: '13:00', + } as Event; + + const result = isOverlapping(meeting1, meeting2); + + expect(result).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + const existingEvents = [ + { id: '1', date: '2025-07-01', startTime: '09:00', endTime: '10:00' }, + { id: '2', date: '2025-07-01', startTime: '11:00', endTime: '12:00' }, + { id: '3', date: '2025-07-01', startTime: '09:30', endTime: '10:30' }, + ] as Event[]; + + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const newEvent = { id: '4', date: '2025-07-01', startTime: '09:15', endTime: '09:45' } as Event; + const result = findOverlappingEvents(newEvent, existingEvents); + + expect(result).toEqual([ + { id: '1', date: '2025-07-01', startTime: '09:00', endTime: '10:00' }, + { id: '3', date: '2025-07-01', startTime: '09:30', endTime: '10:30' }, + ]); + }); + + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent = { id: '4', date: '2025-07-01', startTime: '12:00', endTime: '13:00' } as Event; + const result = findOverlappingEvents(newEvent, existingEvents); - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + expect(result).toEqual([]); + }); }); From 7212157dd20c99ae5362d6bf6c9378200968529c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 02:29:17 +0900 Subject: [PATCH 05/27] =?UTF-8?q?chore:=20dateUtils=20=EB=AA=A8=EB=93=A0?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/dateUtils.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index 8f3457a3..23d4b6bb 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -26,6 +26,10 @@ export function getWeekDates(date: Date): Date[] { return weekDates; } +/** + * 주어진 날짜가 속한 월의 주별 달력 배열을 반환합니다. + * 각 주는 7개 요소로 구성되며, 해당 월에 속하지 않는 날짜는 null로 표시됩니다. + */ export function getWeeksAtMonth(currentDate: Date) { const year = currentDate.getFullYear(); const month = currentDate.getMonth(); @@ -54,10 +58,16 @@ export function getWeeksAtMonth(currentDate: Date) { return weeks; } +/** + * 이벤트 배열에서 특정 일자에 해당하는 이벤트들을 필터링하여 반환합니다. + */ export function getEventsForDay(events: Event[], date: number): Event[] { return events.filter((event) => new Date(event.date).getDate() === date); } +/** + * 주어진 날짜가 속한 주차 정보를 "YYYY년 M월 N주" 형식으로 반환합니다. + */ export function formatWeek(targetDate: Date) { const dayOfWeek = targetDate.getDay(); const diffToThursday = 4 - dayOfWeek; @@ -87,6 +97,9 @@ export function formatMonth(date: Date): string { return `${year}년 ${month}월`; } +/** + * Date 객체에서 시간 정보를 제거하고 날짜만 남깁니다. + */ const stripTime = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); /** @@ -100,10 +113,17 @@ export function isDateInRange(date: Date, rangeStart: Date, rangeEnd: Date): boo return normalizedDate >= normalizedStart && normalizedDate <= normalizedEnd; } +/** + * 숫자를 지정된 자릿수만큼 0으로 채워서 문자열로 반환합니다. + */ export function fillZero(value: number, size = 2) { return String(value).padStart(size, '0'); } +/** + * 날짜를 YYYY-MM-DD 형식의 문자열로 포맷팅합니다. + * day 매개변수가 제공되면 해당 일자로 대체합니다. + */ export function formatDate(currentDate: Date, day?: number) { return [ currentDate.getFullYear(), From 50888a435450b15a4965163999371303fb598a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 02:30:53 +0900 Subject: [PATCH 06/27] =?UTF-8?q?chore:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=A8=20=EA=B2=80=EC=82=AC=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=93=A4=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A3=BC=EC=84=9D=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/utils/eventOverlap.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/utils/eventOverlap.ts b/src/utils/eventOverlap.ts index 8412cee7..c500ee14 100644 --- a/src/utils/eventOverlap.ts +++ b/src/utils/eventOverlap.ts @@ -1,9 +1,15 @@ import { Event, EventForm } from '../types'; +/** + * 날짜 문자열과 시간 문자열을 조합하여 Date 객체를 생성합니다. + */ export function parseDateTime(date: string, time: string) { return new Date(`${date}T${time}`); } +/** + * 이벤트 객체를 시작/종료 Date 객체를 포함한 날짜 범위 객체로 변환합니다. + */ export function convertEventToDateRange({ date, startTime, endTime }: Event | EventForm) { return { start: parseDateTime(date, startTime), @@ -11,6 +17,11 @@ export function convertEventToDateRange({ date, startTime, endTime }: Event | Ev }; } +/** + * 두 이벤트의 시간이 겹치는지 확인합니다. + * 시작 시간이 다른 이벤트의 종료 시간보다 이전이고, + * 종료 시간이 다른 이벤트의 시작 시간보다 이후일 때 겹치는 것으로 판단합니다. + */ export function isOverlapping(event1: Event | EventForm, event2: Event | EventForm) { const { start: start1, end: end1 } = convertEventToDateRange(event1); const { start: start2, end: end2 } = convertEventToDateRange(event2); @@ -18,6 +29,10 @@ export function isOverlapping(event1: Event | EventForm, event2: Event | EventFo return start1 < end2 && start2 < end1; } +/** + * 기존 이벤트 배열에서 새 이벤트와 시간이 겹치는 이벤트들을 찾아 반환합니다. + * 자기 자신(같은 ID)은 제외하고 검사합니다. + */ export function findOverlappingEvents(newEvent: Event | EventForm, events: Event[]) { return events.filter( (event) => event.id !== (newEvent as Event).id && isOverlapping(event, newEvent) From 591d005ad51cf9787451ce0b85da5a2dfe009188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 02:50:28 +0900 Subject: [PATCH 07/27] =?UTF-8?q?chore:=20eventUtils=20=EB=AA=A8=EB=93=A0?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=EB=93=A4=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/eventUtils.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/utils/eventUtils.ts b/src/utils/eventUtils.ts index 9e75e947..d7abeae5 100644 --- a/src/utils/eventUtils.ts +++ b/src/utils/eventUtils.ts @@ -1,6 +1,9 @@ import { Event } from '../types'; import { getWeekDates, isDateInRange } from './dateUtils'; +/** + * 지정된 날짜 범위 내에 있는 이벤트들을 필터링하여 반환합니다. + */ function filterEventsByDateRange(events: Event[], start: Date, end: Date): Event[] { return events.filter((event) => { const eventDate = new Date(event.date); @@ -8,10 +11,16 @@ function filterEventsByDateRange(events: Event[], start: Date, end: Date): Event }); } +/** + * 문자열에서 검색어가 포함되어 있는지 대소문자 구분 없이 확인합니다. + */ function containsTerm(target: string, term: string) { return target.toLowerCase().includes(term.toLowerCase()); } +/** + * 이벤트 배열에서 제목, 설명, 위치에 검색어가 포함된 이벤트들을 찾아 반환합니다. + */ function searchEvents(events: Event[], term: string) { return events.filter( ({ title, description, location }) => @@ -19,11 +28,18 @@ function searchEvents(events: Event[], term: string) { ); } +/** + * 주어진 날짜가 속한 주(일요일~토요일) 범위의 이벤트들을 필터링하여 반환합니다. + */ function filterEventsByDateRangeAtWeek(events: Event[], currentDate: Date) { const weekDates = getWeekDates(currentDate); return filterEventsByDateRange(events, weekDates[0], weekDates[6]); } +/** + * 주어진 날짜가 속한 월 전체 범위의 이벤트들을 필터링하여 반환합니다. + * 월의 마지막 날 23시 59분 59초까지를 포함합니다. + */ function filterEventsByDateRangeAtMonth(events: Event[], currentDate: Date) { const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); const monthEnd = new Date( @@ -38,6 +54,10 @@ function filterEventsByDateRangeAtMonth(events: Event[], currentDate: Date) { return filterEventsByDateRange(events, monthStart, monthEnd); } +/** + * 검색어와 뷰 타입(주간/월간)에 따라 이벤트들을 필터링하여 반환합니다. + * 먼저 검색어로 필터링한 후, 뷰 타입에 따라 날짜 범위를 적용합니다. + */ export function getFilteredEvents( events: Event[], searchTerm: string, From bc3f0ec86426a8203b4c76508fd3821166b41e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 02:50:53 +0900 Subject: [PATCH 08/27] =?UTF-8?q?feat:=20eventUtils=20=EB=AA=A8=EB=93=A0?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=EB=93=A4=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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/__tests__/unit/easy.eventUtils.spec.ts | 154 +++++++++++++++++++-- 1 file changed, 145 insertions(+), 9 deletions(-) diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..cf7e4069 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -1,20 +1,156 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; -describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); +describe.only('getFilteredEvents', () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + description: '첫 번째 이벤트', + location: '서울', + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + description: '두 번째 이벤트', + location: '부산', + }, + { + id: '3', + title: '회의', + date: '2025-07-01', + description: '이벤트 2 관련 회의', + location: '대구', + }, + { + id: '4', + title: 'EVENT 1', + date: '2025-07-02', + description: 'Event 관련 회의', + location: '광주', + }, + { + id: '5', + title: '세미나', + date: '2025-07-03', + description: '교육 세미나', + location: '인천', + }, + { + id: '6', + title: '이벤트 3', + date: '2025-07-07', + description: '주말 이벤트', + location: '울산', + }, + { + id: '7', + title: '회의 2', + date: '2025-07-08', + description: '다음 주 회의', + location: '대전', + }, + { + id: '8', + title: '7월 마지막 이벤트', + date: '2025-07-31', + description: '7월 마지막 날 이벤트', + location: '제주', + }, + { + id: '9', + title: '6월 이벤트', + date: '2025-06-30', + description: '6월 마지막 날 이벤트', + location: '춘천', + }, + { + id: '10', + title: '8월 이벤트', + date: '2025-08-01', + description: '8월 첫 번째 이벤트', + location: '강릉', + }, + ] as Event[]; - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + const result = getFilteredEvents(mockEvents, '이벤트 2', new Date('2025-07-01'), 'month'); - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + expect(result).toHaveLength(2); + expect(result[0].title).toBe('이벤트 2'); + expect(result[1].description).toContain('이벤트 2'); + }); - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + const result = getFilteredEvents(mockEvents, '', new Date('2025-07-01'), 'week'); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + expect(result).toHaveLength(6); + expect( + result.every((event) => { + const eventDate = new Date(event.date); + const startOfWeek = new Date('2025-06-29'); + const endOfWeek = new Date('2025-07-05'); + return eventDate >= startOfWeek && eventDate <= endOfWeek; + }) + ).toBe(true); + }); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + const result = getFilteredEvents(mockEvents, '', new Date('2025-07-15'), 'month'); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + expect(result).toHaveLength(8); + expect(result.every((event) => event.date.startsWith('2025-07'))).toBe(true); + }); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + const result = getFilteredEvents(mockEvents, '이벤트', new Date('2025-07-01'), 'week'); + + expect(result).toHaveLength(4); + expect( + result.every( + (event) => + event.title.toLowerCase().includes('이벤트') || + event.description.toLowerCase().includes('이벤트') + ) + ).toBe(true); + }); + + it('검색어가 없을 때 모든 이벤트를 반환한다', () => { + const julyEvents = mockEvents.filter((event) => event.date.startsWith('2025-07')); + const result = getFilteredEvents(mockEvents, '', new Date('2025-07-01'), 'month'); + + expect(result).toHaveLength(8); + expect(result).toEqual(julyEvents); + }); + + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const result = getFilteredEvents(mockEvents, 'event', new Date('2025-07-01'), 'month'); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('EVENT 1'); + }); + + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const result = getFilteredEvents(mockEvents, '', new Date('2025-07-15'), 'month'); + const julyEvents = result.filter((event) => event.date.startsWith('2025-07')); + + expect(julyEvents).toHaveLength(8); + expect(julyEvents.some((event) => event.date === '2025-07-01')).toBe(true); + expect(julyEvents.some((event) => event.date === '2025-07-31')).toBe(true); + expect( + julyEvents.every( + (event) => !event.date.startsWith('2025-06') && !event.date.startsWith('2025-08') + ) + ).toBe(true); + }); + + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const emptyEvents: Event[] = []; + const result = getFilteredEvents(emptyEvents, '이벤트', new Date('2025-07-01'), 'month'); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); }); From 3a13e3ced592146ca27cc77e9da10866eee3f9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 11:47:25 +0900 Subject: [PATCH 09/27] =?UTF-8?q?chore:=20fetchHolidays=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A3=BC=EC=84=9D=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/easy.eventUtils.spec.ts | 2 +- src/apis/fetchHolidays.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index cf7e4069..cc4532e3 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -1,7 +1,7 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; -describe.only('getFilteredEvents', () => { +describe('getFilteredEvents', () => { const mockEvents: Event[] = [ { id: '1', diff --git a/src/apis/fetchHolidays.ts b/src/apis/fetchHolidays.ts index de03b992..d09e75b9 100644 --- a/src/apis/fetchHolidays.ts +++ b/src/apis/fetchHolidays.ts @@ -18,6 +18,11 @@ const HOLIDAY_RECORD = { type HolidayRecord = typeof HOLIDAY_RECORD; type HolidayKeys = keyof HolidayRecord; +/** + * 주어진 날짜가 속한 월의 모든 공휴일을 반환합니다. + * @param date - 조회할 월이 포함된 Date 객체 + * @returns 해당 월의 공휴일 객체 (키: YYYY-MM-DD 형식의 날짜, 값: 공휴일명) + */ export function fetchHolidays(date: Date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); From e834e741da84d1204192426ee4362be0f6527a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 11:47:53 +0900 Subject: [PATCH 10/27] =?UTF-8?q?feat:=20fetchHolidays=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=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 --- src/__tests__/unit/easy.fetchHolidays.spec.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..ac9e2dc9 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,28 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + const result = fetchHolidays(new Date('2025-08-21')); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + expect(result).toEqual({ + '2025-08-15': '광복절', + }); + }); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + const result = fetchHolidays(new Date('2025-07-21')); + + expect(result).toEqual({}); + }); + + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + const result = fetchHolidays(new Date('2025-01-01')); + console.log(result); + + expect(result).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + }); }); From cc6fc11320eba4f0a4dfb1214c831bfb1446d480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 12:17:03 +0900 Subject: [PATCH 11/27] =?UTF-8?q?chore:=20notificationUtils=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A3=BC=EC=84=9D=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/utils/notificationUtils.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/utils/notificationUtils.ts b/src/utils/notificationUtils.ts index 16ba8bdd..226a534d 100644 --- a/src/utils/notificationUtils.ts +++ b/src/utils/notificationUtils.ts @@ -3,6 +3,13 @@ import { Event } from '../types'; const 초 = 1000; const 분 = 초 * 60; +/** + * 현재 시점에서 알림이 필요한 다가오는 이벤트들을 필터링하여 반환합니다. + * @param events - 전체 이벤트 배열 + * @param now - 현재 시간 + * @param notifiedEvents - 이미 알림이 전송된 이벤트 ID 배열 + * @returns 알림이 필요한 이벤트 배열 + */ export function getUpcomingEvents(events: Event[], now: Date, notifiedEvents: string[]) { return events.filter((event) => { const eventStart = new Date(`${event.date}T${event.startTime}`); @@ -11,6 +18,11 @@ export function getUpcomingEvents(events: Event[], now: Date, notifiedEvents: st }); } +/** + * 이벤트 정보를 바탕으로 알림 메시지를 생성합니다. + * @param event - 알림 메시지를 생성할 이벤트 객체 (notificationTime, title 속성 사용) + * @returns 생성된 알림 메시지 문자열 + */ export function createNotificationMessage({ notificationTime, title }: Event) { return `${notificationTime}분 후 ${title} 일정이 시작됩니다.`; } From add9c2f22212c6abed83f476dd3046530068fcdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 12:17:34 +0900 Subject: [PATCH 12/27] =?UTF-8?q?feat:=20notificationUtils=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=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/easy.notificationUtils.spec.ts | 74 +++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..19733932 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -1,16 +1,80 @@ import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; +const events = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + notificationTime: 1, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-02', + startTime: '10:00', + endTime: '11:00', + notificationTime: 1, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-07-03', + startTime: '11:00', + endTime: '12:00', + notificationTime: 1, + }, +] as Event[]; + describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + const now = new Date('2025-07-01T08:59'); + const notifiedEvents = ['3']; + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + notificationTime: 1, + }, + ]); + }); + + it('이미 알림이 간 이벤트는 제외한다', () => { + const now = new Date('2025-07-01T08:59'); + const notifiedEvents = ['1', '2']; + const result = getUpcomingEvents(events, now, notifiedEvents); - it('이미 알림이 간 이벤트는 제외한다', () => {}); + expect(result).toEqual([]); + }); - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + const now = new Date('2025-07-01T08:00'); + const notifiedEvents = ['3']; + const result = getUpcomingEvents(events, now, notifiedEvents); - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + expect(result).toEqual([]); + }); + + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const now = new Date('2025-07-01T11:00'); + const notifiedEvents = ['3']; + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([]); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const result = createNotificationMessage(events[0]); + + expect(result).toBe('1분 후 이벤트 1 일정이 시작됩니다.'); + }); }); From 1d1e2e4b6a20b91a3c27e64cf9df212dac98ad02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 12:56:25 +0900 Subject: [PATCH 13/27] =?UTF-8?q?chore:=20timeValidation=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A3=BC=EC=84=9D=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/utils/timeValidation.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/utils/timeValidation.ts b/src/utils/timeValidation.ts index 20dde5de..2c16567d 100644 --- a/src/utils/timeValidation.ts +++ b/src/utils/timeValidation.ts @@ -3,14 +3,23 @@ export interface TimeValidationResult { endTimeError: string | null; } +/** + * 시작 시간과 종료 시간의 유효성을 검증하고 에러 메시지를 반환합니다. + * @param start - 시작 시간 (HH:MM 형식, 예: "09:00") + * @param end - 종료 시간 (HH:MM 형식, 예: "10:00") + * @returns 시간 유효성 검증 결과 객체 (각 필드는 에러가 없으면 null, 있으면 에러 메시지) + */ export function getTimeErrorMessage(start: string, end: string): TimeValidationResult { + // 1. 시작 시간이나 종료 시간이 비어있으면 검증하지 않음 if (!start || !end) { return { startTimeError: null, endTimeError: null }; } + // 2. 시간 문자열을 Date 객체로 변환 (같은 날짜를 기준으로 시간만 비교) const startDate = new Date(`2000-01-01T${start}`); const endDate = new Date(`2000-01-01T${end}`); + // 3. 시작 시간이 종료 시간보다 늦거나 같으면 에러 if (startDate >= endDate) { return { startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', @@ -18,5 +27,6 @@ export function getTimeErrorMessage(start: string, end: string): TimeValidationR }; } + // 4. 모든 검증 통과 시 에러 없음 return { startTimeError: null, endTimeError: null }; } From df04b04d48570fcd4d9b7054872dfa839e788928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 12:56:45 +0900 Subject: [PATCH 14/27] =?UTF-8?q?feat:=20timeValidation=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=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/easy.timeValidation.spec.ts | 54 ++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..3b5101c0 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,57 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('10:00', '08:00'); - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + expect(result).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('08:00', '08:00'); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + expect(result).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + const result = getTimeErrorMessage('08:00', '10:00'); - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + expect(result).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); + + it('시작 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', '10:00'); + + expect(result).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); + + it('종료 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('08:00', ''); + + expect(result).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); + + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', ''); + + expect(result).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); }); From d6fe7ea6435917c79947a493a53e72e5afa96178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 16:33:34 +0900 Subject: [PATCH 15/27] =?UTF-8?q?feat:=20=EB=82=A0=EC=A7=9C=EB=A7=8C=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=ED=95=98=EB=8A=94=20assertDateOnly=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 8e419c87..30fbbd8c 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -4,6 +4,10 @@ export const assertDate = (date1: Date, date2: Date) => { expect(date1.toISOString()).toBe(date2.toISOString()); }; +export const assertDateOnly = (date1: Date, date2: Date) => { + expect(date1.toDateString()).toBe(date2.toDateString()); +}; + export const parseHM = (timestamp: number) => { const date = new Date(timestamp); const h = fillZero(date.getHours()); From 4e45623125d70de7a2f74b400557f6be3f885359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 16:41:07 +0900 Subject: [PATCH 16/27] =?UTF-8?q?feat:=20useCalendarView=20=ED=9B=85?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=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 --- .../hooks/easy.useCalendarView.spec.ts | 119 ++++++++++++++++-- 1 file changed, 109 insertions(+), 10 deletions(-) diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..00a6eef2 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -1,24 +1,123 @@ import { act, renderHook } from '@testing-library/react'; import { useCalendarView } from '../../hooks/useCalendarView.ts'; -import { assertDate } from '../utils.ts'; +import { assertDateOnly } from '../utils.ts'; describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + expect(result.current.view).toBe('month'); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); + it('currentDate는 현재 날짜이어야 한다', () => { + const now = new Date(); + const { result } = renderHook(() => useCalendarView()); + + assertDateOnly(result.current.currentDate, now); + }); + + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-10-01')); + }); + + expect(result.current.holidays).toEqual({ + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-03': '개천절', + '2025-10-09': '한글날', + }); + }); +}); + +it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); + + expect(result.current.view).toBe('month'); + + act(() => { + result.current.setView('week'); + }); + + expect(result.current.view).toBe('week'); }); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); +it('주간 뷰에서 2025-10-01 기준으로 다음 주로 이동하면 2025-10-08이 된다', () => { + const { result } = renderHook(() => useCalendarView()); + const startDate = new Date('2025-10-01'); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + act(() => { + result.current.setView('week'); + result.current.setCurrentDate(startDate); + }); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + act(() => { + result.current.navigate('next'); + }); + + const expectedDate = new Date('2025-10-08'); + expect(result.current.currentDate).toEqual(expectedDate); +}); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); +it('주간 뷰에서 2025-10-01 기준으로 이전 주로 이동하면 2025-09-24가 된다', () => { + const { result } = renderHook(() => useCalendarView()); + const startDate = new Date('2025-10-01'); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); + act(() => { + result.current.setView('week'); + result.current.setCurrentDate(startDate); + }); + + act(() => { + result.current.navigate('prev'); + }); + + const expectedDate = new Date('2025-09-24'); + expect(result.current.currentDate).toEqual(expectedDate); +}); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); +it('월간 뷰에서 2025-10-01 기준으로 한 달 후 이동하면 2025-11-01가 된다', () => { + const { result } = renderHook(() => useCalendarView()); + const startDate = new Date('2025-10-01'); + + act(() => { + result.current.setCurrentDate(startDate); + }); + + act(() => { + result.current.navigate('next'); + }); + + const expectedDate = new Date('2025-11-01'); + expect(result.current.currentDate).toEqual(expectedDate); +}); + +it('월간 뷰에서 2025-10-01 기준으로 한 달 전 이동하면 2025-09-01이 된다', () => { + const { result } = renderHook(() => useCalendarView()); + const startDate = new Date('2025-10-01'); + + act(() => { + result.current.setCurrentDate(startDate); + }); + + act(() => { + result.current.navigate('prev'); + }); + + const expectedDate = new Date('2025-09-01'); + expect(result.current.currentDate).toEqual(expectedDate); +}); + +it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => { + const { result } = renderHook(() => useCalendarView()); + + await act(async () => { + result.current.setCurrentDate(new Date('2025-03-01')); + }); + + expect(result.current.holidays['2025-03-01']).toBe('삼일절'); +}); From 31fd5d32f9d73b1cc28468ea0b996af3d01808a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 17:23:07 +0900 Subject: [PATCH 17/27] =?UTF-8?q?feat:=20useSearch=20=ED=9B=85=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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 --- src/__tests__/hooks/easy.useSearch.spec.ts | 119 ++++++++++++++++++++- 1 file changed, 114 insertions(+), 5 deletions(-) diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 80f57fa3..035d13a5 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -3,12 +3,121 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; import { Event } from '../../types.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +const mockEvents: Event[] = [ + { + id: '1', + title: '팀 회의', + date: '2025-08-20', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + { + id: '2', + title: '점심 약속', + date: '2025-08-21', + startTime: '12:00', + endTime: '13:00', + description: '동료와 점심식사', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + { + id: '3', + title: '프로젝트 마감', + date: '2025-08-25', + startTime: '14:00', + endTime: '15:00', + description: '분기별 프로젝트 마감', + location: '사무실', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + { + id: '4', + title: '생일 파티', + date: '2025-08-28', + startTime: '19:00', + endTime: '20:00', + description: '친구 생일 축하', + location: '친구 집', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + { + id: '5', + title: '운동', + date: '2025-08-22', + startTime: '10:00', + endTime: '11:00', + description: '주간 운동', + location: '헬스장', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, +]; -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); +it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date(), 'month')); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); + expect(result.current.filteredEvents).toEqual(mockEvents); +}); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); +it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date(), 'month')); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); + act(() => { + result.current.setSearchTerm('팀 회의'); + }); + + expect(result.current.filteredEvents).toEqual([mockEvents[0]]); +}); + +it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date(), 'month')); + + act(() => { + result.current.setSearchTerm('주간 운동'); + }); + + expect(result.current.filteredEvents).toEqual([mockEvents[4]]); +}); + +it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const currentDate = new Date(); + + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + + expect(result.current.filteredEvents.map((e) => e.id)).toEqual(['1', '2', '5']); +}); + +it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date(), 'month')); + + // 1. '회의' 검색 + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.searchTerm).toBe('회의'); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents.map((e) => e.id)).toEqual(['1']); + + // 2. '점심' 검색으로 변경 + act(() => { + result.current.setSearchTerm('점심'); + }); + + expect(result.current.searchTerm).toBe('점심'); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents.map((e) => e.id)).toEqual(['2']); +}); From ab885a46cbe0c9a0a6d3d4bf99111f55b878b4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 20:17:28 +0900 Subject: [PATCH 18/27] =?UTF-8?q?feat:=20useEventOperations=20=ED=9B=85=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/medium.useEventOperations.spec.ts | 187 +++++++++++++++++- 1 file changed, 178 insertions(+), 9 deletions(-) diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..3276353a 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { @@ -8,7 +8,7 @@ import { } from '../../__mocks__/handlersUtils.ts'; import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; -import { Event } from '../../types.ts'; +import { Event, EventForm } from '../../types.ts'; const enqueueSnackbarFn = vi.fn(); @@ -22,16 +22,185 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + const mockEvents = [ + { + id: 'test-event-1', + title: '테스트 이벤트', + date: '2025-01-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트용 이벤트', + location: '테스트 장소', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + ]; + setupMockHandlerCreation(mockEvents as Event[]); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + await waitFor(() => { + expect(result.current.events).toEqual(mockEvents); + }); +}); + +it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + setupMockHandlerCreation([]); + + const newEvent = { + title: '새로운 회의', + date: '2025-01-15', + startTime: '14:00', + endTime: '15:00', + description: '새 프로젝트 미팅', + location: '회의실 C', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 5, + } as EventForm; + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.saveEvent(newEvent); + }); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0]).toMatchObject(newEvent); + expect(result.current.events[0].id).toBeDefined(); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', { variant: 'success' }); +}); + +it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + const existingEvent = { + id: 'existing-1', + title: '기존 회의', + date: '2025-01-15', + startTime: '10:00', + endTime: '11:00', + description: '기존 설명', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + } as Event; + + setupMockHandlerUpdating([existingEvent]); + + const { result } = renderHook(() => useEventOperations(true)); + + const updatedEvent = { + ...existingEvent, + title: '수정된 회의', + endTime: '12:00', + } as EventForm; + + await act(async () => { + await result.current.saveEvent(updatedEvent); + }); + + await waitFor(() => { + const updatedEvent = result.current.events.find((event) => event.id === existingEvent.id); + expect(updatedEvent?.title).toBe('수정된 회의'); + expect(updatedEvent?.endTime).toBe('12:00'); + expect(updatedEvent?.startTime).toBe('10:00'); + expect(updatedEvent?.location).toBe('회의실 A'); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', { variant: 'success' }); +}); + +it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + const existingEvent = { + id: 'existing-1', + title: '기존 회의', + date: '2025-01-15', + startTime: '10:00', + endTime: '11:00', + description: '기존 설명', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + } as Event; + + setupMockHandlerDeletion([existingEvent]); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + await act(async () => { + await result.current.deleteEvent(existingEvent.id); + }); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + await waitFor(() => { + expect(result.current.events).toHaveLength(0); + }); -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { variant: 'info' }); +}); + +it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ error: 'Server Error' }, { status: 500 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); + }); + + expect(result.current.events).toHaveLength(0); +}); + +it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + server.use( + http.put('/api/events/:id', () => { + return HttpResponse.json({ error: 'Event not found' }, { status: 404 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(true)); + + const nonExistentEvent = { + id: 'non-existent-id', + title: '수정할 이벤트', + date: '2025-01-15', + startTime: '10:00', + endTime: '11:00', + description: '존재하지 않는 이벤트', + location: '어딘가', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }; + + await act(async () => { + await result.current.saveEvent(nonExistentEvent); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); +}); + +it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + server.use( + http.delete('/api/events/:id', () => { + return HttpResponse.json({ error: 'Network Error' }, { status: 500 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.deleteEvent('existing-1'); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 삭제 실패', { variant: 'error' }); +}); From d7387ea0d14551671353ee892a87588d3ed78199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 21 Aug 2025 23:42:21 +0900 Subject: [PATCH 19/27] =?UTF-8?q?feat:=20useNotifications=20=ED=9B=85=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/medium.useNotifications.spec.ts | 111 +++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts index 7f585ea8..ee7961b7 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -5,10 +5,113 @@ import { Event } from '../../types.ts'; import { formatDate } from '../../utils/dateUtils.ts'; import { parseHM } from '../utils.ts'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +it('초기 상태에서는 알림이 없어야 한다', () => { + const { result } = renderHook(() => useNotifications([])); -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); + expect(result.current.notifications).toEqual([]); + expect(result.current.notifiedEvents).toEqual([]); +}); -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); +it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + // 1. 시간 모킹 + vi.useFakeTimers(); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); + // 2. 시간 설정: 8시 59분 + vi.setSystemTime(new Date('2025-07-01T08:59:00')); + + // 3. 이벤트 데이터 (9시 시작, 1분 전 알림) + const events = [ + { + id: '1', + title: '회의', + date: formatDate(new Date('2025-07-01')), + startTime: parseHM(new Date('2025-07-01T09:00:00').getTime()), + notificationTime: 1, + }, + ] as Event[]; + + // 4. 훅 렌더링 + const { result } = renderHook(() => useNotifications(events)); + + // 5. 초기 상태 확인 + expect(result.current.notifications).toEqual([]); + + // 6. 1초 진행 (setInterval 실행) + act(() => { + vi.advanceTimersByTime(1000); + }); + + // 7. 알림 생성 확인 + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifications[0].message).toBe('1분 후 회의 일정이 시작됩니다.'); + + // 8. 정리 + vi.useRealTimers(); +}); + +it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const { result } = renderHook(() => useNotifications([])); + + act(() => { + result.current.setNotifications([ + { id: '1', message: '첫 번째 알림' }, + { id: '2', message: '두 번째 알림' }, + { id: '3', message: '세 번째 알림' }, + ]); + }); + + expect(result.current.notifications).toHaveLength(3); + + act(() => { + result.current.removeNotification(0); + }); + + expect(result.current.notifications).toHaveLength(2); + expect(result.current.notifications[0].message).toBe('두 번째 알림'); + expect(result.current.notifications[1].message).toBe('세 번째 알림'); +}); + +it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + // 1. 시간 모킹 + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-07-01T08:59:00')); + + // 2. 알림 대상 이벤트 준비 + const events = [ + { + id: 'test-event', + title: '회의', + date: formatDate(new Date('2025-07-01')), + startTime: parseHM(new Date('2025-07-01T09:00:00').getTime()), + notificationTime: 1, + }, + ] as Event[]; + + // 3. 훅 렌더링 + const { result } = renderHook(() => useNotifications(events)); + + // 4. 초기 상태 확인 + expect(result.current.notifications).toHaveLength(0); + expect(result.current.notifiedEvents).toHaveLength(0); + + // 5. 첫 번째 setInterval 실행 → 알림 생성 + act(() => { + vi.advanceTimersByTime(1000); + }); + + // 6. 첫 번째 알림 확인 + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toEqual(['test-event']); + + // 7. 두 번째 setInterval 실행 → 중복 알림 방지 + act(() => { + vi.advanceTimersByTime(1000); + }); + + // 8. 중복 알림이 생성되지 않았는지 확인 + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toHaveLength(1); + + // 9. 정리 + vi.useRealTimers(); +}); From 279b36396ccc8cf9abe34c8d46ebd42d6b9b860b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 22 Aug 2025 03:22:58 +0900 Subject: [PATCH 20/27] =?UTF-8?q?feat:=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 522 +++++++++++++++++++++- 1 file changed, 504 insertions(+), 18 deletions(-) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 15901d4e..79be2c14 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,49 +1,535 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act } from '@testing-library/react'; -import { UserEvent, userEvent } from '@testing-library/user-event'; +import { render, screen, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; -import { ReactElement } from 'react'; +import { + setupMockHandlerCreation, + setupMockHandlerDeletion, + setupMockHandlerUpdating, +} from '../__mocks__/handlersUtils'; import App from '../App'; -import { server } from '../setupTests'; import { Event } from '../types'; +const renderApp = () => { + const theme = createTheme(); + return render( + + + + + + + ); +}; describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { - // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + setupMockHandlerCreation([]); + renderApp(); + + // getElementById로 안정적인 엘리먼트 접근 + const titleInput = document.getElementById('title') as HTMLInputElement; + const dateInput = document.getElementById('date') as HTMLInputElement; + const startTimeInput = document.getElementById('start-time') as HTMLInputElement; + const endTimeInput = document.getElementById('end-time') as HTMLInputElement; + const descriptionInput = document.getElementById('description') as HTMLInputElement; + const locationInput = document.getElementById('location') as HTMLInputElement; + + // userEvent로 사용자 상호작용 시뮬레이션 + await userEvent.type(titleInput, '새로운 회의'); + await userEvent.type(dateInput, '2025-01-15'); + await userEvent.type(startTimeInput, '14:00'); + await userEvent.type(endTimeInput, '15:00'); + await userEvent.type(descriptionInput, '테스트 회의'); + await userEvent.type(locationInput, '회의실 A'); + + await userEvent.click(screen.getByTestId('event-submit-button')); + + // 비동기 처리 완료까지 대기 + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // 성공 확인 - 이벤트 리스트 또는 성공 메시지 + await waitFor(() => { + const eventList = screen.getByTestId('event-list'); + const hasEvent = within(eventList).queryByText('새로운 회의'); + const hasSuccessMessage = screen.queryByText('일정이 추가되었습니다.'); + + expect(hasEvent || hasSuccessMessage).toBeTruthy(); + }); + }); + + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + const existingEvent: Event = { + id: 'test-event-1', + title: '기존 회의', + date: '2025-08-21', + startTime: '10:00', + endTime: '11:00', + description: '기존 설명', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + setupMockHandlerUpdating([existingEvent]); + + const user = userEvent.setup(); + renderApp(); + + // 기존 이벤트가 표시되는지 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText(existingEvent.title)).toBeInTheDocument(); + + // 수정 버튼 클릭 + const editButton = await screen.findByLabelText('Edit event'); + await user.click(editButton); + + // 폼이 기존 값으로 채워졌는지 확인 + const titleField = await screen.findByLabelText('제목'); + expect(titleField).toHaveValue(existingEvent.title); + + // 값 수정 + await user.clear(titleField); + await user.type(titleField, '수정된 회의'); + + const descriptionField = await screen.findByLabelText('설명'); + await user.clear(descriptionField); + await user.type(descriptionField, '수정된 설명'); + + const locationField = await screen.findByLabelText('위치'); + await user.clear(locationField); + await user.type(locationField, '회의실 C'); + + // 수정 버튼 클릭 + const submitButton = screen.getByTestId('event-submit-button'); + await user.click(submitButton); + + // 수정 완료 메시지 확인 + expect(await screen.findByText('일정이 수정되었습니다.')).toBeInTheDocument(); + + // 수정된 내용이 목록에 반영되었는지 확인 + expect(await within(eventList).findByText('수정된 회의')).toBeInTheDocument(); + expect(await within(eventList).findByText('수정된 설명')).toBeInTheDocument(); + expect(await within(eventList).findByText('회의실 C')).toBeInTheDocument(); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + const existingEvent: Event = { + id: 'delete-test-event', + title: '삭제될 회의', + date: '2025-08-21', + startTime: '10:00', + endTime: '11:00', + description: '삭제될 설명', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + setupMockHandlerDeletion([existingEvent]); + + const user = userEvent.setup(); + renderApp(); + + // 기존 이벤트가 표시되는지 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText(existingEvent.title)).toBeInTheDocument(); + + // 삭제 버튼 클릭 + const deleteButton = await screen.findByLabelText('Delete event'); + await user.click(deleteButton); + + // 삭제 완료 메시지 확인 + expect(await screen.findByText('일정이 삭제되었습니다.')).toBeInTheDocument(); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + // 이벤트가 목록에서 사라졌는지 확인 + expect(within(eventList).queryByText(existingEvent.title)).not.toBeInTheDocument(); + + // "검색 결과가 없습니다." 메시지가 표시되는지 확인 + expect(await within(eventList).findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + setupMockHandlerCreation([]); + + renderApp(); + + // 기본 렌더링이 완료될 때까지 대기 + expect(await screen.findByTestId('event-submit-button')).toBeInTheDocument(); + + // 뷰 변경 없이 기본 상태에서 이벤트 리스트 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + const testEvent: Event = { + id: 'week-test-event', + title: '주간 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '주간 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + setupMockHandlerCreation([testEvent]); + + renderApp(); + + // 기본적으로 주별 뷰로 시작하므로 바로 확인 + // 이벤트 리스트에서 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText(testEvent.title)).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + setupMockHandlerCreation([]); + + const user = userEvent.setup(); + renderApp(); + + // 월별 뷰 선택 + const viewSelect = await screen.findByLabelText('뷰 타입 선택'); + await user.click(viewSelect); + + await waitFor(async () => { + const monthOption = screen.getByText('Month'); + expect(monthOption).toBeInTheDocument(); + }); + + const monthOption = screen.getByText('Month'); + await user.click(monthOption); + + // 월별 뷰가 표시되는지 확인 + const monthView = await screen.findByTestId('month-view'); + expect(monthView).toBeInTheDocument(); + + // 일정이 없는지 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + const testEvent: Event = { + id: 'month-test-event', + title: '월간 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '월간 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + setupMockHandlerCreation([testEvent]); + + const user = userEvent.setup(); + renderApp(); + + // 월별 뷰 선택 + const viewSelect = await screen.findByLabelText('뷰 타입 선택'); + await user.click(viewSelect); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + const monthOption = await screen.findByText('Month'); + await user.click(monthOption); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + // 월별 뷰에서 이벤트 확인 + const monthView = await screen.findByTestId('month-view'); + expect(monthView).toBeInTheDocument(); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + // 이벤트 리스트에서도 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText(testEvent.title)).toBeInTheDocument(); + }); + + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + setupMockHandlerCreation([]); + + const user = userEvent.setup(); + renderApp(); + + // 월별 뷰 선택 + const viewSelect = await screen.findByLabelText('뷰 타입 선택'); + await user.click(viewSelect); + + const monthOption = await screen.findByText('Month'); + await user.click(monthOption); + + // 1월로 이동 (현재가 8월이라고 가정하고 7번 이전 버튼 클릭) + const prevButton = await screen.findByLabelText('Previous'); + for (let i = 0; i < 7; i++) { + await user.click(prevButton); + } - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + // 월별 뷰에서 신정 확인 + const monthView = await screen.findByTestId('month-view'); + expect(monthView).toBeInTheDocument(); + + // 신정이 표시되는지 확인 + expect(await within(monthView).findByText('신정')).toBeInTheDocument(); + expect(await within(monthView).findByText('1')).toBeInTheDocument(); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + setupMockHandlerCreation([]); + + const user = userEvent.setup(); + renderApp(); + + // 검색 필드에 존재하지 않는 일정 검색 + const searchField = await screen.findByLabelText('일정 검색'); + await user.type(searchField, '존재하지 않는 일정'); + + // 검색 결과가 없다는 메시지 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + const testEvents: Event[] = [ + { + id: 'search-test-event-1', + title: '팀 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '주간 팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: 'search-test-event-2', + title: '개인 일정', + date: '2025-08-21', + startTime: '16:00', + endTime: '17:00', + description: '개인적인 일정', + location: '카페', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + setupMockHandlerCreation(testEvents); + + const user = userEvent.setup(); + renderApp(); + + // 검색 필드에 '팀 회의' 입력 + const searchField = await screen.findByLabelText('일정 검색'); + await user.type(searchField, '팀 회의'); + + // 검색 결과 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText('팀 회의')).toBeInTheDocument(); + expect(within(eventList).queryByText('개인 일정')).not.toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + const testEvents: Event[] = [ + { + id: 'search-test-event-1', + title: '팀 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '주간 팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: 'search-test-event-2', + title: '개인 일정', + date: '2025-08-21', + startTime: '16:00', + endTime: '17:00', + description: '개인적인 일정', + location: '카페', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + setupMockHandlerCreation(testEvents); + + const user = userEvent.setup(); + renderApp(); + + // 검색 필드에 '팀 회의' 입력 + const searchField = await screen.findByLabelText('일정 검색'); + await user.type(searchField, '팀 회의'); + + // 검색 결과 확인 - '팀 회의'만 표시 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText('팀 회의')).toBeInTheDocument(); + expect(within(eventList).queryByText('개인 일정')).not.toBeInTheDocument(); + + // 검색어 지우기 + await user.clear(searchField); + + // 모든 일정이 다시 표시되는지 확인 + expect(await within(eventList).findByText('팀 회의')).toBeInTheDocument(); + expect(await within(eventList).findByText('개인 일정')).toBeInTheDocument(); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + const existingEvent: Event = { + id: 'overlap-test-event', + title: '기존 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '기존 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + setupMockHandlerCreation([existingEvent]); + + const user = userEvent.setup(); + renderApp(); + + // 겹치는 시간대의 새 일정 입력 + await user.type(await screen.findByLabelText('제목'), '새로운 회의'); + await user.type(await screen.findByLabelText('날짜'), '2025-08-21'); + await user.type(await screen.findByLabelText('시작 시간'), '14:30'); + await user.type(await screen.findByLabelText('종료 시간'), '15:30'); + await user.type(await screen.findByLabelText('설명'), '겹치는 회의'); + await user.type(await screen.findByLabelText('위치'), '회의실 B'); + await user.type(await screen.findByLabelText('카테고리'), '업무'); + + // 일정 추가 버튼 클릭 + const eventSubmitButton = screen.getByTestId('event-submit-button'); + await user.click(eventSubmitButton); + + // 충돌 경고 대화상자 확인 + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + expect(await screen.findByText(/다음 일정과 겹칩니다/)).toBeInTheDocument(); + expect(await screen.findByText(/기존 회의.*2025-08-21 14:00-15:00/)).toBeInTheDocument(); + + // 대화상자 버튼들 확인 + expect(await screen.findByText('취소')).toBeInTheDocument(); + expect(await screen.findByText('계속 진행')).toBeInTheDocument(); + }); + + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + const existingEvents: Event[] = [ + { + id: 'overlap-test-event-1', + title: '첫 번째 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '첫 번째 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: 'overlap-test-event-2', + title: '두 번째 회의', + date: '2025-08-21', + startTime: '16:00', + endTime: '17:00', + description: '두 번째 회의입니다', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + setupMockHandlerUpdating(existingEvents); + + const user = userEvent.setup(); + renderApp(); + + // 첫 번째 회의 수정 버튼 클릭 + const editButtons = await screen.findAllByLabelText('Edit event'); + await user.click(editButtons[0]); + + // 시간을 두 번째 회의와 겹치도록 수정 + const startTimeField = await screen.findByLabelText('시작 시간'); + const endTimeField = await screen.findByLabelText('종료 시간'); + + await user.clear(startTimeField); + await user.type(startTimeField, '16:30'); + await user.clear(endTimeField); + await user.type(endTimeField, '17:30'); + + // 수정 버튼 클릭 + const submitButton = screen.getByTestId('event-submit-button'); + await user.click(submitButton); + + // 충돌 경고 대화상자 확인 + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + expect(await screen.findByText(/다음 일정과 겹칩니다/)).toBeInTheDocument(); + expect(await screen.findByText(/두 번째 회의.*2025-08-21 16:00-17:00/)).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + // 현재 시간으로부터 10분 후 일정 생성 + const now = new Date(); + const eventTime = new Date(now.getTime() + 10 * 60 * 1000); // 10분 후 + + const testEvent: Event = { + id: 'notification-test-event', + title: '알림 테스트 회의', + date: eventTime.toISOString().split('T')[0], + startTime: eventTime.toTimeString().slice(0, 5), + endTime: new Date(eventTime.getTime() + 60 * 60 * 1000).toTimeString().slice(0, 5), + description: '알림 테스트용 회의', + location: '테스트 룸', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, // 10분 전 알림 + }; + + setupMockHandlerCreation([testEvent]); + + renderApp(); + + // 이벤트 리스트에서 이벤트와 알림 설정 확인 + const eventList = screen.getByTestId('event-list'); + + // 이벤트가 표시될 때까지 기다림 + await waitFor( + () => { + const eventItem = within(eventList).queryByText('알림 테스트 회의'); + expect(eventItem).toBeTruthy(); + }, + { timeout: 3000 } + ); + + // 이벤트가 리스트에 표시되는지 확인 + expect(await within(eventList).findByText('알림 테스트 회의')).toBeInTheDocument(); + + // 알림 설정이 10분 전으로 되어있는지 확인 (이벤트 리스트 내에서) + expect(await within(eventList).findByText('알림: 10분 전')).toBeInTheDocument(); +}); From 37e7512bc024b1e8bebed6b9bd0020e911de53f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 22 Aug 2025 03:27:13 +0900 Subject: [PATCH 21/27] =?UTF-8?q?chore:=20ESLint=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/package.json b/package.json index b01b2b4b..d3fd7c3b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@eslint/js": "^9.33.0", "@mui/icons-material": "7.2.0", "@mui/material": "7.2.0", "express": "^4.19.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093f3ec7..af349b60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@emotion/styled': specifier: ^11.11.5 version: 11.13.0(@emotion/react@11.13.3(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0) + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 '@mui/icons-material': specifier: 7.2.0 version: 7.2.0(@mui/material@7.2.0(@emotion/react@11.13.3(@types/react@19.1.8)(react@19.1.0))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.8)(react@19.1.0) @@ -468,6 +471,10 @@ packages: resolution: {integrity: sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.33.0': + resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3628,6 +3635,8 @@ snapshots: '@eslint/js@9.30.0': {} + '@eslint/js@9.33.0': {} + '@eslint/object-schema@2.1.6': {} '@eslint/plugin-kit@0.3.3': From 0fc2980bdb3d65830bff481a0b8fc8f604966980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 22 Aug 2025 03:29:07 +0900 Subject: [PATCH 22/27] =?UTF-8?q?chore:=20ESLint=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 79be2c14..318694aa 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -2,7 +2,6 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import { render, screen, within, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; import { From 62377e67ba9b0807c0b2b59476f02c4297d253c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 22 Aug 2025 09:53:21 +0900 Subject: [PATCH 23/27] =?UTF-8?q?chore:=20ESLint=20=EC=84=A4=EC=A0=95=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 --- eslint.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 0a019971..36ca9cdd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -70,7 +70,8 @@ export default [ ...typescriptPlugin.configs.recommended.rules, // ESLint rules - 'no-unused-vars': 'warn', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error'], // React rules 'react/prop-types': 'off', From 854c02120c59122104feecea952df1fe6876253e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 22 Aug 2025 09:53:52 +0900 Subject: [PATCH 24/27] =?UTF-8?q?refactor:=20App=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 676 +++------------------------ src/components/CalendarView.tsx | 210 +++++++++ src/components/EventForm.tsx | 278 +++++++++++ src/components/EventList.tsx | 112 +++++ src/components/NotificationToast.tsx | 33 ++ src/components/OverlapDialog.tsx | 48 ++ 6 files changed, 758 insertions(+), 599 deletions(-) create mode 100644 src/components/CalendarView.tsx create mode 100644 src/components/EventForm.tsx create mode 100644 src/components/EventList.tsx create mode 100644 src/components/NotificationToast.tsx create mode 100644 src/components/OverlapDialog.tsx diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..25d4dea4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,103 +1,29 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material'; -import { - Alert, - AlertTitle, - Box, - Button, - Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - FormControl, - FormControlLabel, - FormLabel, - IconButton, - MenuItem, - Select, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TextField, - Tooltip, - Typography, -} from '@mui/material'; -import { useSnackbar } from 'notistack'; +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { Box, IconButton, MenuItem, Select, Stack, Typography } from '@mui/material'; import { useState } from 'react'; +import { CalendarView } from './components/CalendarView'; +import { EventForm } from './components/EventForm'; +import { EventList } from './components/EventList'; +import { NotificationToast } from './components/NotificationToast'; +import { OverlapDialog } from './components/OverlapDialog'; 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, - formatMonth, - formatWeek, - getEventsForDay, - getWeekDates, - getWeeksAtMonth, -} from './utils/dateUtils'; -import { findOverlappingEvents } from './utils/eventOverlap'; -import { getTimeErrorMessage } from './utils/timeValidation'; - -const categories = ['업무', '개인', '가족', '기타']; - -const weekDays = ['일', '월', '화', '수', '목', '금', '토']; - -const notificationOptions = [ - { value: 1, label: '1분 전' }, - { value: 10, label: '10분 전' }, - { value: 60, label: '1시간 전' }, - { value: 120, label: '2시간 전' }, - { value: 1440, label: '1일 전' }, -]; +import { Event, EventForm as EventFormType } from './types'; function App() { - const { - title, - setTitle, - date, - setDate, - startTime, - endTime, - description, - setDescription, - location, - setLocation, - category, - setCategory, - isRepeating, - setIsRepeating, - repeatType, - // setRepeatType, - repeatInterval, - // setRepeatInterval, - repeatEndDate, - // setRepeatEndDate, - notificationTime, - setNotificationTime, - startTimeError, - endTimeError, - editingEvent, - setEditingEvent, - handleStartTimeChange, - handleEndTimeChange, - resetForm, - editEvent, - } = useEventForm(); - - const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () => - setEditingEvent(null) + const eventFormHook = useEventForm(); + const { setEditingEvent, editEvent } = eventFormHook; + const { events, saveEvent, deleteEvent } = useEventOperations( + !!eventFormHook.editingEvent, + () => { + setEditingEvent(null); + eventFormHook.resetForm(); + } ); - const { notifications, notifiedEvents, setNotifications } = useNotifications(events); const { view, setView, currentDate, holidays, navigate } = useCalendarView(); const { searchTerm, filteredEvents, setSearchTerm } = useSearch(events, currentDate, view); @@ -105,387 +31,48 @@ function App() { const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); const [overlappingEvents, setOverlappingEvents] = useState([]); - const { enqueueSnackbar } = useSnackbar(); - - const addOrUpdateEvent = async () => { - if (!title || !date || !startTime || !endTime) { - enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); - return; - } - - if (startTimeError || endTimeError) { - enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); - return; - } - - const eventData: Event | EventForm = { - id: editingEvent ? editingEvent.id : undefined, - title, - date, - startTime, - endTime, - description, - location, - category, - repeat: { - type: isRepeating ? repeatType : 'none', - interval: repeatInterval, - endDate: repeatEndDate || undefined, - }, - notificationTime, - }; - - const overlapping = findOverlappingEvents(eventData, events); - if (overlapping.length > 0) { - setOverlappingEvents(overlapping); - setIsOverlapDialogOpen(true); - } else { - await saveEvent(eventData); - resetForm(); - } + const handleSubmitEvent = async (eventData: Event | EventFormType) => { + await saveEvent(eventData); }; - const renderWeekView = () => { - const weekDates = getWeekDates(currentDate); - return ( - - {formatWeek(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - - {weekDates.map((date) => ( - - - {date.getDate()} - - {filteredEvents - .filter( - (event) => new Date(event.date).toDateString() === date.toDateString() - ) - .map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - ))} - - -
-
-
- ); + const handleOverlapDetected = (overlapping: Event[]) => { + setOverlappingEvents(overlapping); + setIsOverlapDialogOpen(true); }; - const renderMonthView = () => { - const weeks = getWeeksAtMonth(currentDate); - - return ( - - {formatMonth(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - {weeks.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => { - const dateString = day ? formatDate(currentDate, day) : ''; - const holiday = holidays[dateString]; - - return ( - - {day && ( - <> - - {day} - - {holiday && ( - - {holiday} - - )} - {getEventsForDay(filteredEvents, day).map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - )} - - ); - })} - - ))} - -
-
-
- ); + const handleConfirmOverlap = async () => { + setIsOverlapDialogOpen(false); + if (overlappingEvents.length > 0) { + // 실제 폼 데이터 사용 + const eventData = { + id: eventFormHook.editingEvent?.id, + title: eventFormHook.title, + date: eventFormHook.date, + startTime: eventFormHook.startTime, + endTime: eventFormHook.endTime, + description: eventFormHook.description, + location: eventFormHook.location, + category: eventFormHook.category, + repeat: { + type: eventFormHook.isRepeating ? eventFormHook.repeatType : ('none' as const), + interval: eventFormHook.repeatInterval, + endDate: eventFormHook.repeatEndDate || undefined, + }, + notificationTime: eventFormHook.notificationTime, + }; + await saveEvent(eventData); + } }; return ( - - {editingEvent ? '일정 수정' : '일정 추가'} - - - 제목 - setTitle(e.target.value)} - /> - - - - 날짜 - setDate(e.target.value)} - /> - - - - - 시작 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!startTimeError} - /> - - - - 종료 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!endTimeError} - /> - - - - - - 설명 - setDescription(e.target.value)} - /> - - - - 위치 - setLocation(e.target.value)} - /> - - - - 카테고리 - - - - - setIsRepeating(e.target.checked)} - /> - } - label="반복 일정" - /> - - - - 알림 설정 - - - - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( - - - 반복 유형 - - - - - 반복 간격 - setRepeatInterval(Number(e.target.value))} - slotProps={{ htmlInput: { min: 1 } }} - /> - - - 반복 종료일 - setRepeatEndDate(e.target.value)} - /> - - - - )} */} - - - + 일정 보기 @@ -512,147 +99,38 @@ function App() { - {view === 'week' && renderWeekView()} - {view === 'month' && renderMonthView()} + - - - 일정 검색 - setSearchTerm(e.target.value)} - /> - - - {filteredEvents.length === 0 ? ( - 검색 결과가 없습니다. - ) : ( - filteredEvents.map((event) => ( - - - - - {notifiedEvents.includes(event.id) && } - - {event.title} - - - {event.date} - - {event.startTime} - {event.endTime} - - {event.description} - {event.location} - 카테고리: {event.category} - {event.repeat.type !== 'none' && ( - - 반복: {event.repeat.interval} - {event.repeat.type === 'daily' && '일'} - {event.repeat.type === 'weekly' && '주'} - {event.repeat.type === 'monthly' && '월'} - {event.repeat.type === 'yearly' && '년'} - 마다 - {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`} - - )} - - 알림:{' '} - { - notificationOptions.find( - (option) => option.value === event.notificationTime - )?.label - } - - - - editEvent(event)}> - - - deleteEvent(event.id)}> - - - - - - )) - )} - + - setIsOverlapDialogOpen(false)}> - 일정 겹침 경고 - - - 다음 일정과 겹칩니다: - {overlappingEvents.map((event) => ( - - {event.title} ({event.date} {event.startTime}-{event.endTime}) - - ))} - 계속 진행하시겠습니까? - - - - - - - - - {notifications.length > 0 && ( - - {notifications.map((notification, index) => ( - setNotifications((prev) => prev.filter((_, i) => i !== index))} - > - - - } - > - {notification.message} - - ))} - - )} + setIsOverlapDialogOpen(false)} + onConfirm={handleConfirmOverlap} + /> + + + setNotifications((prev) => prev.filter((_, i) => i !== index)) + } + /> ); } diff --git a/src/components/CalendarView.tsx b/src/components/CalendarView.tsx new file mode 100644 index 00000000..79a62839 --- /dev/null +++ b/src/components/CalendarView.tsx @@ -0,0 +1,210 @@ +import { Notifications } from '@mui/icons-material'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { Event } from '../types'; +import { + formatDate, + formatMonth, + formatWeek, + getEventsForDay, + getWeekDates, + getWeeksAtMonth, +} from '../utils/dateUtils'; + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +interface CalendarViewProps { + view: 'week' | 'month'; + currentDate: Date; + events: Event[]; + holidays: Record; + notifiedEvents: string[]; +} + +export const CalendarView = ({ + view, + currentDate, + events, + holidays, + notifiedEvents, +}: CalendarViewProps) => { + const renderWeekView = () => { + const weekDates = getWeekDates(currentDate); + return ( + + {formatWeek(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + + {weekDates.map((date) => ( + + + {date.getDate()} + + {events + .filter( + (event) => new Date(event.date).toDateString() === date.toDateString() + ) + .map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + ))} + + +
+
+
+ ); + }; + + const renderMonthView = () => { + const weeks = getWeeksAtMonth(currentDate); + + return ( + + {formatMonth(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + {weeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + const dateString = day ? formatDate(currentDate, day) : ''; + const holiday = holidays[dateString]; + + return ( + + {day && ( + <> + + {day} + + {holiday && ( + + {holiday} + + )} + {getEventsForDay(events, day).map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + )} + + ); + })} + + ))} + +
+
+
+ ); + }; + + return view === 'week' ? renderWeekView() : renderMonthView(); +}; diff --git a/src/components/EventForm.tsx b/src/components/EventForm.tsx new file mode 100644 index 00000000..43ea18b8 --- /dev/null +++ b/src/components/EventForm.tsx @@ -0,0 +1,278 @@ +import { + Button, + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; + +import { useEventForm } from '../hooks/useEventForm'; +import { Event, EventForm as EventFormType } from '../types'; +import { findOverlappingEvents } from '../utils/eventOverlap'; +import { getTimeErrorMessage } from '../utils/timeValidation'; + +const categories = ['업무', '개인', '가족', '기타']; + +const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +interface EventFormProps { + events: Event[]; + onSubmit: (eventData: Event | EventFormType) => Promise; + onOverlapDetected: (overlappingEvents: Event[]) => void; + eventFormHook: ReturnType; +} + +export const EventForm = ({ + events, + onSubmit, + onOverlapDetected, + eventFormHook, +}: EventFormProps) => { + const { + title, + setTitle, + date, + setDate, + startTime, + endTime, + description, + setDescription, + location, + setLocation, + category, + setCategory, + isRepeating, + setIsRepeating, + repeatType, + repeatInterval, + repeatEndDate, + notificationTime, + setNotificationTime, + startTimeError, + endTimeError, + editingEvent, + handleStartTimeChange, + handleEndTimeChange, + } = eventFormHook; + + const { enqueueSnackbar } = useSnackbar(); + + const handleSubmit = async () => { + if (!title || !date || !startTime || !endTime) { + enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); + return; + } + + if (startTimeError || endTimeError) { + enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); + return; + } + + const eventData: Event | EventFormType = { + id: editingEvent ? editingEvent.id : undefined, + title, + date, + startTime, + endTime, + description, + location, + category, + repeat: { + type: isRepeating ? repeatType : 'none', + interval: repeatInterval, + endDate: repeatEndDate || undefined, + }, + notificationTime, + }; + + const overlapping = findOverlappingEvents(eventData, events); + if (overlapping.length > 0) { + onOverlapDetected(overlapping); + } else { + await onSubmit(eventData); + } + }; + + return ( + + {editingEvent ? '일정 수정' : '일정 추가'} + + + 제목 + setTitle(e.target.value)} + /> + + + + 날짜 + setDate(e.target.value)} + /> + + + + + 시작 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!startTimeError} + /> + + + + 종료 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!endTimeError} + /> + + + + + + 설명 + setDescription(e.target.value)} + /> + + + + 위치 + setLocation(e.target.value)} + /> + + + + 카테고리 + + + + + setIsRepeating(e.target.checked)} /> + } + label="반복 일정" + /> + + + + 알림 설정 + + + + {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} + {/* {isRepeating && ( + + + 반복 유형 + + + + + 반복 간격 + setRepeatInterval(Number(e.target.value))} + slotProps={{ htmlInput: { min: 1 } }} + /> + + + 반복 종료일 + setRepeatEndDate(e.target.value)} + /> + + + + )} */} + + + + ); +}; diff --git a/src/components/EventList.tsx b/src/components/EventList.tsx new file mode 100644 index 00000000..2af8ec6f --- /dev/null +++ b/src/components/EventList.tsx @@ -0,0 +1,112 @@ +import { Delete, Edit, Notifications } from '@mui/icons-material'; +import { + Box, + FormControl, + FormLabel, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { Event } from '../types'; + +const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +interface EventListProps { + events: Event[]; + notifiedEvents: string[]; + searchTerm: string; + onSearchChange: (term: string) => void; + onEditEvent: (event: Event) => void; + onDeleteEvent: (id: string) => void; +} + +export const EventList = ({ + events, + notifiedEvents, + searchTerm, + onSearchChange, + onEditEvent, + onDeleteEvent, +}: EventListProps) => { + return ( + + + 일정 검색 + onSearchChange(e.target.value)} + /> + + + {events.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + events.map((event) => ( + + + + + {notifiedEvents.includes(event.id) && } + + {event.title} + + + {event.date} + + {event.startTime} - {event.endTime} + + {event.description} + {event.location} + 카테고리: {event.category} + {event.repeat.type !== 'none' && ( + + 반복: {event.repeat.interval} + {event.repeat.type === 'daily' && '일'} + {event.repeat.type === 'weekly' && '주'} + {event.repeat.type === 'monthly' && '월'} + {event.repeat.type === 'yearly' && '년'} + 마다 + {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`} + + )} + + 알림:{' '} + { + notificationOptions.find((option) => option.value === event.notificationTime) + ?.label + } + + + + onEditEvent(event)}> + + + onDeleteEvent(event.id)}> + + + + + + )) + )} + + ); +}; diff --git a/src/components/NotificationToast.tsx b/src/components/NotificationToast.tsx new file mode 100644 index 00000000..98ac11ea --- /dev/null +++ b/src/components/NotificationToast.tsx @@ -0,0 +1,33 @@ +import { Close } from '@mui/icons-material'; +import { Alert, AlertTitle, IconButton, Stack } from '@mui/material'; + +interface NotificationToastProps { + notifications: Array<{ id: string; message: string }>; + onRemoveNotification: (index: number) => void; +} + +export const NotificationToast = ({ + notifications, + onRemoveNotification, +}: NotificationToastProps) => { + if (notifications.length === 0) return null; + + return ( + + {notifications.map((notification, index) => ( + onRemoveNotification(index)}> + + + } + > + {notification.message} + + ))} + + ); +}; diff --git a/src/components/OverlapDialog.tsx b/src/components/OverlapDialog.tsx new file mode 100644 index 00000000..5f13023c --- /dev/null +++ b/src/components/OverlapDialog.tsx @@ -0,0 +1,48 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from '@mui/material'; + +import { Event } from '../types'; + +interface OverlapDialogProps { + open: boolean; + overlappingEvents: Event[]; + onClose: () => void; + onConfirm: () => void; +} + +export const OverlapDialog = ({ + open, + overlappingEvents, + onClose, + onConfirm, +}: OverlapDialogProps) => { + return ( + + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +}; From 5587e527d52979b8968af70d7faadcffea2d286d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 22 Aug 2025 09:54:44 +0900 Subject: [PATCH 25/27] =?UTF-8?q?refactor:=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/advanced.dateUtils.spec.ts | 306 ++++++++++++++ .../utils/advanced.eventOverlap.spec.ts | 384 ++++++++++++++++++ .../utils/advanced.timeValidation.spec.ts | 214 ++++++++++ 3 files changed, 904 insertions(+) create mode 100644 src/__tests__/utils/advanced.dateUtils.spec.ts create mode 100644 src/__tests__/utils/advanced.eventOverlap.spec.ts create mode 100644 src/__tests__/utils/advanced.timeValidation.spec.ts diff --git a/src/__tests__/utils/advanced.dateUtils.spec.ts b/src/__tests__/utils/advanced.dateUtils.spec.ts new file mode 100644 index 00000000..a6cc1ba8 --- /dev/null +++ b/src/__tests__/utils/advanced.dateUtils.spec.ts @@ -0,0 +1,306 @@ +import { describe, expect } from 'vitest'; + +import { Event } from '../../types'; +import { + formatDate, + formatMonth, + formatWeek, + getEventsForDay, + getWeekDates, + getWeeksAtMonth, + isDateInRange, +} from '../../utils/dateUtils'; + +describe('dateUtils', () => { + describe('formatDate 함수', () => { + it('주어진 날짜를 YYYY-MM-DD 형식으로 포맷한다', () => { + const date = new Date('2024-07-15'); + const result = formatDate(date, 15); + + expect(result).toBe('2024-07-15'); + }); + + it('월의 첫날을 올바르게 포맷한다', () => { + const date = new Date('2024-07-01'); + const result = formatDate(date, 1); + + expect(result).toBe('2024-07-01'); + }); + + it('월의 마지막날을 올바르게 포맷한다', () => { + const date = new Date('2024-07-31'); + const result = formatDate(date, 31); + + expect(result).toBe('2024-07-31'); + }); + + it('12월의 날짜를 올바르게 포맷한다', () => { + const date = new Date('2024-12-25'); + const result = formatDate(date, 25); + + expect(result).toBe('2024-12-25'); + }); + + it('윤년의 2월 29일을 올바르게 처리한다', () => { + const date = new Date('2024-02-29'); + const result = formatDate(date, 29); + + expect(result).toBe('2024-02-29'); + }); + }); + + describe('formatMonth 함수', () => { + it('월을 한국어 형식으로 포맷한다', () => { + const date = new Date('2024-07-15'); + const result = formatMonth(date); + + expect(result).toBe('2024년 7월'); + }); + + it('연도 경계의 월을 올바르게 포맷한다', () => { + const january = new Date('2024-01-01'); + const december = new Date('2024-12-31'); + + expect(formatMonth(january)).toBe('2024년 1월'); + expect(formatMonth(december)).toBe('2024년 12월'); + }); + + it('다른 연도의 월을 올바르게 포맷한다', () => { + const date = new Date('2025-03-15'); + const result = formatMonth(date); + + expect(result).toBe('2025년 3월'); + }); + }); + + describe('formatWeek 함수', () => { + it('주차를 한국어 형식으로 포맷한다', () => { + const date = new Date('2024-07-15'); // 월요일 + const result = formatWeek(date); + + expect(result).toBe('2024년 7월 3주'); + }); + + it('월의 첫 주를 올바르게 포맷한다', () => { + const date = new Date('2024-07-01'); + const result = formatWeek(date); + + expect(result).toBe('2024년 7월 1주'); + }); + + it('월의 마지막 주를 올바르게 포맷한다', () => { + const date = new Date('2024-07-29'); + const result = formatWeek(date); + + expect(result).toBe('2024년 8월 1주'); + }); + }); + + describe('getWeekDates 함수', () => { + it('주어진 날짜가 속한 주의 모든 날짜를 반환한다', () => { + const date = new Date('2024-07-15'); // 월요일 + const result = getWeekDates(date); + + expect(result).toHaveLength(7); + expect(result[0].getDay()).toBe(0); // 일요일 + expect(result[6].getDay()).toBe(6); // 토요일 + }); + + it('일요일이 주의 시작이다', () => { + const monday = new Date('2024-07-15'); + const result = getWeekDates(monday); + + expect(result[0].getDay()).toBe(0); // 일요일 + expect(result[0].getDate()).toBe(14); // 7월 14일 (일요일) + }); + + it('연도를 넘나드는 주를 올바르게 처리한다', () => { + const endOfYear = new Date('2023-12-31'); // 일요일 + const result = getWeekDates(endOfYear); + + expect(result).toHaveLength(7); + expect(result[0].getFullYear()).toBe(2023); + expect(result[6].getFullYear()).toBe(2024); + }); + + it('월을 넘나드는 주를 올바르게 처리한다', () => { + const endOfMonth = new Date('2024-07-31'); + const result = getWeekDates(endOfMonth); + + expect(result).toHaveLength(7); + // 일부는 7월, 일부는 8월에 속해야 함 + }); + }); + + describe('getWeeksAtMonth 함수', () => { + it('월의 모든 주를 반환한다', () => { + const date = new Date('2024-07-15'); + const result = getWeeksAtMonth(date); + + expect(result.length).toBeGreaterThanOrEqual(4); + expect(result.length).toBeLessThanOrEqual(6); + }); + + it('각 주는 7일을 가진다', () => { + const date = new Date('2024-07-15'); + const result = getWeeksAtMonth(date); + + result.forEach((week) => { + expect(week).toHaveLength(7); + }); + }); + + it('월의 첫날과 마지막날이 포함된다', () => { + const date = new Date('2024-07-15'); + const result = getWeeksAtMonth(date); + + const allDays = result.flat(); + const monthDays = allDays.filter((day) => day !== null); + + expect(monthDays).toContain(1); // 첫날 + expect(monthDays).toContain(31); // 마지막날 + }); + + it('2월의 주를 올바르게 처리한다', () => { + const february = new Date('2024-02-15'); + const result = getWeeksAtMonth(february); + + const allDays = result.flat(); + const monthDays = allDays.filter((day) => day !== null); + + expect(monthDays).toContain(1); + expect(monthDays).toContain(29); // 윤년 + }); + + it('평년 2월을 올바르게 처리한다', () => { + const february = new Date('2023-02-15'); + const result = getWeeksAtMonth(february); + + const allDays = result.flat(); + const monthDays = allDays.filter((day) => day !== null); + + expect(monthDays).toContain(1); + expect(monthDays).toContain(28); // 평년 + expect(monthDays).not.toContain(29); + }); + }); + + describe('getEventsForDay 함수', () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2024-07-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '2', + title: '이벤트 2', + date: '2024-07-16', + startTime: '14:00', + endTime: '15:00', + description: '', + location: '', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '3', + title: '이벤트 3', + date: '2024-07-15', + startTime: '11:00', + endTime: '12:00', + description: '', + location: '', + category: '가족', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + it('특정 날짜의 이벤트들만 반환한다', () => { + const result = getEventsForDay(mockEvents, 15); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('1'); + expect(result[1].id).toBe('3'); + }); + + it('해당 날짜에 이벤트가 없으면 빈 배열을 반환한다', () => { + const result = getEventsForDay(mockEvents, 20); + + expect(result).toHaveLength(0); + }); + + it('이벤트가 시간 순으로 정렬되어 반환된다', () => { + const result = getEventsForDay(mockEvents, 15); + + expect(result[0].startTime).toBe('09:00'); + expect(result[1].startTime).toBe('11:00'); + }); + }); + + describe('isDateInRange 함수', () => { + it('범위 내의 날짜에 대해 true를 반환한다', () => { + const date = new Date('2024-07-15'); + const start = new Date('2024-07-01'); + const end = new Date('2024-07-31'); + + expect(isDateInRange(date, start, end)).toBe(true); + }); + + it('범위 경계의 날짜에 대해 true를 반환한다', () => { + const start = new Date('2024-07-01'); + const end = new Date('2024-07-31'); + + expect(isDateInRange(start, start, end)).toBe(true); + expect(isDateInRange(end, start, end)).toBe(true); + }); + + it('범위를 벗어난 날짜에 대해 false를 반환한다', () => { + const date = new Date('2024-08-01'); + const start = new Date('2024-07-01'); + const end = new Date('2024-07-31'); + + expect(isDateInRange(date, start, end)).toBe(false); + }); + + it('시작일보다 이전 날짜에 대해 false를 반환한다', () => { + const date = new Date('2024-06-30'); + const start = new Date('2024-07-01'); + const end = new Date('2024-07-31'); + + expect(isDateInRange(date, start, end)).toBe(false); + }); + }); + + describe('경계값 테스트', () => { + it('윤년을 올바르게 처리한다', () => { + const leapYear = new Date('2024-02-29'); + const result = formatDate(leapYear, 29); + + expect(result).toBe('2024-02-29'); + }); + + it('연도 경계를 올바르게 처리한다', () => { + const newYear = new Date('2024-01-01'); + const result = formatDate(newYear, 1); + + expect(result).toBe('2024-01-01'); + }); + + it('월 경계를 올바르게 처리한다', () => { + const monthEnd = new Date('2024-01-31'); + const result = formatDate(monthEnd, 31); + + expect(result).toBe('2024-01-31'); + }); + }); +}); diff --git a/src/__tests__/utils/advanced.eventOverlap.spec.ts b/src/__tests__/utils/advanced.eventOverlap.spec.ts new file mode 100644 index 00000000..d99f264f --- /dev/null +++ b/src/__tests__/utils/advanced.eventOverlap.spec.ts @@ -0,0 +1,384 @@ +import { describe, expect } from 'vitest'; + +import { Event, EventForm } from '../../types'; +import { findOverlappingEvents } from '../../utils/eventOverlap'; + +describe('eventOverlap', () => { + const existingEvents: Event[] = [ + { + id: '1', + title: '기존 회의', + date: '2024-07-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2024-07-15', + startTime: '12:00', + endTime: '13:00', + description: '', + location: '', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '3', + title: '다른 날 일정', + date: '2024-07-16', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + describe('findOverlappingEvents 함수', () => { + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '11:00', + endTime: '12:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, existingEvents); + expect(result).toHaveLength(0); + }); + + it('시간이 완전히 겹치는 이벤트를 찾는다', () => { + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, existingEvents); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('부분적으로 겹치는 이벤트를 찾는다 - 시작시간 겹침', () => { + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '08:30', + endTime: '09:30', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, existingEvents); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('부분적으로 겹치는 이벤트를 찾는다 - 종료시간 겹침', () => { + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '09:30', + endTime: '10:30', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, existingEvents); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('기존 이벤트를 완전히 포함하는 새 이벤트를 찾는다', () => { + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '08:00', + endTime: '11:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, existingEvents); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('새 이벤트가 기존 이벤트에 완전히 포함되는 경우를 찾는다', () => { + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '09:15', + endTime: '09:45', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, existingEvents); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('여러 이벤트와 겹치는 경우 모두 반환한다', () => { + const newEvent: EventForm = { + title: '긴 일정', + date: '2024-07-15', + startTime: '08:00', + endTime: '14:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, existingEvents); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id)).toContain('1'); + expect(result.map((e) => e.id)).toContain('2'); + }); + + it('다른 날짜의 이벤트는 겹치지 않는다', () => { + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-17', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, existingEvents); + expect(result).toHaveLength(0); + }); + + it('시간이 정확히 연결되는 경우는 겹치지 않는다', () => { + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, existingEvents); + expect(result).toHaveLength(0); + }); + + it('편집 중인 이벤트는 제외한다', () => { + const editingEvent: Event = { + id: '1', + title: '수정된 회의', + date: '2024-07-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(editingEvent, existingEvents); + expect(result).toHaveLength(0); + }); + }); + + describe('경계값 테스트', () => { + it('자정을 넘나드는 시간을 올바르게 처리한다', () => { + const lateEvent: Event = { + id: '4', + title: '늦은 이벤트', + date: '2024-07-15', + startTime: '23:00', + endTime: '23:59', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '23:30', + endTime: '23:45', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, [lateEvent]); + expect(result).toHaveLength(1); + }); + + it('같은 시작 시간을 가진 이벤트들을 올바르게 처리한다', () => { + const sameStartEvent: Event = { + id: '4', + title: '같은 시작', + date: '2024-07-15', + startTime: '09:00', + endTime: '09:30', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, [sameStartEvent]); + expect(result).toHaveLength(1); + }); + + it('같은 종료 시간을 가진 이벤트들을 올바르게 처리한다', () => { + const sameEndEvent: Event = { + id: '4', + title: '같은 종료', + date: '2024-07-15', + startTime: '09:30', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, [sameEndEvent]); + expect(result).toHaveLength(1); + }); + + it('1분 이벤트도 올바르게 처리한다', () => { + const shortEvent: Event = { + id: '4', + title: '짧은 이벤트', + date: '2024-07-15', + startTime: '09:30', + endTime: '09:31', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, [shortEvent]); + expect(result).toHaveLength(1); + }); + }); + + describe('예외 상황 처리', () => { + it('빈 이벤트 배열에 대해 빈 배열을 반환한다', () => { + const newEvent: EventForm = { + title: '새 일정', + date: '2024-07-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(newEvent, []); + expect(result).toHaveLength(0); + }); + + it('잘못된 시간 순서도 처리한다', () => { + const invalidEvent: EventForm = { + title: '잘못된 일정', + date: '2024-07-15', + startTime: '10:00', + endTime: '09:00', // 종료 시간이 시작 시간보다 빠름 + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = findOverlappingEvents(invalidEvent, existingEvents); + // 잘못된 시간이라도 함수가 에러 없이 실행되어야 함 + expect(result).toBeDefined(); + }); + }); +}); diff --git a/src/__tests__/utils/advanced.timeValidation.spec.ts b/src/__tests__/utils/advanced.timeValidation.spec.ts new file mode 100644 index 00000000..f9efbcff --- /dev/null +++ b/src/__tests__/utils/advanced.timeValidation.spec.ts @@ -0,0 +1,214 @@ +import { describe, expect } from 'vitest'; + +import { getTimeErrorMessage } from '../../utils/timeValidation'; + +describe('timeValidation', () => { + describe('getTimeErrorMessage 함수', () => { + it('올바른 시간 순서일 때 에러가 없는 객체를 반환한다', () => { + const result = getTimeErrorMessage('09:00', '10:00'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('10:00', '09:00'); + expect(result.startTimeError).toBeTruthy(); + expect(result.endTimeError).toBeTruthy(); + expect(typeof result.startTimeError).toBe('string'); + expect(typeof result.endTimeError).toBe('string'); + }); + + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('09:00', '09:00'); + expect(result.startTimeError).toBeTruthy(); + expect(result.endTimeError).toBeTruthy(); + }); + + it('자정을 넘나드는 시간을 올바르게 처리한다', () => { + const result = getTimeErrorMessage('23:00', '23:59'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('하루의 시작과 끝 시간을 올바르게 처리한다', () => { + const result = getTimeErrorMessage('00:00', '23:59'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('1분 차이 시간을 올바르게 처리한다', () => { + const result = getTimeErrorMessage('09:00', '09:01'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('시간 형식이 올바르지 않을 때도 처리한다', () => { + // 잘못된 형식이라도 함수가 에러를 내지 않아야 함 + expect(() => getTimeErrorMessage('25:00', '10:00')).not.toThrow(); + expect(() => getTimeErrorMessage('09:00', '25:00')).not.toThrow(); + expect(() => getTimeErrorMessage('invalid', '10:00')).not.toThrow(); + }); + + it('빈 문자열 입력을 처리한다', () => { + expect(() => getTimeErrorMessage('', '10:00')).not.toThrow(); + expect(() => getTimeErrorMessage('09:00', '')).not.toThrow(); + expect(() => getTimeErrorMessage('', '')).not.toThrow(); + }); + + describe('경계값 테스트', () => { + it('자정 시간을 올바르게 처리한다', () => { + const result = getTimeErrorMessage('00:00', '00:01'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('23:59 시간을 올바르게 처리한다', () => { + const result = getTimeErrorMessage('23:58', '23:59'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('점심시간대 일반적인 시간을 처리한다', () => { + const result = getTimeErrorMessage('12:00', '13:00'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('오전/오후 경계 시간을 처리한다', () => { + const result = getTimeErrorMessage('11:59', '12:01'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('새벽 시간을 처리한다', () => { + const result = getTimeErrorMessage('01:00', '02:00'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('늦은 밤 시간을 처리한다', () => { + const result = getTimeErrorMessage('22:00', '23:00'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + }); + + describe('에러 케이스', () => { + it('큰 시간 차이로 잘못된 순서일 때', () => { + const result = getTimeErrorMessage('18:00', '09:00'); + expect(result).toBeTruthy(); + }); + + it('1분 차이로 잘못된 순서일 때', () => { + const result = getTimeErrorMessage('09:01', '09:00'); + expect(result).toBeTruthy(); + }); + + it('자정 근처에서 잘못된 순서일 때', () => { + const result = getTimeErrorMessage('23:59', '23:58'); + expect(result).toBeTruthy(); + }); + + it('같은 시간일 때 여러 케이스', () => { + expect(getTimeErrorMessage('09:00', '09:00')).toBeTruthy(); + expect(getTimeErrorMessage('12:30', '12:30')).toBeTruthy(); + expect(getTimeErrorMessage('23:59', '23:59')).toBeTruthy(); + expect(getTimeErrorMessage('00:00', '00:00')).toBeTruthy(); + }); + }); + + describe('특수 형식 테스트', () => { + it('초 단위가 포함된 시간도 처리한다', () => { + // 실제 input[type="time"]에서는 초가 나오지 않지만, + // 혹시 모를 경우를 대비 + expect(() => getTimeErrorMessage('09:00:00', '10:00:00')).not.toThrow(); + }); + + it('단일 자리 시간을 처리한다', () => { + expect(() => getTimeErrorMessage('9:00', '10:00')).not.toThrow(); + expect(() => getTimeErrorMessage('09:0', '10:00')).not.toThrow(); + }); + + it('공백이 포함된 시간을 처리한다', () => { + expect(() => getTimeErrorMessage(' 09:00 ', '10:00')).not.toThrow(); + expect(() => getTimeErrorMessage('09:00', ' 10:00 ')).not.toThrow(); + }); + }); + + describe('자정 넘어가는 시간 처리 경계값 테스트', () => { + it('23:30-01:30과 같이 자정을 넘는 시간은 현재 구현에서 에러로 처리된다', () => { + // 현재 timeValidation은 같은 날짜 기준으로만 비교하므로 자정 넘는 시간은 에러 + const result = getTimeErrorMessage('23:30', '01:30'); + expect(result.startTimeError).toBeTruthy(); + expect(result.endTimeError).toBeTruthy(); + expect(result.startTimeError).toContain('시작 시간은 종료 시간보다 빨라야 합니다'); + expect(result.endTimeError).toContain('종료 시간은 시작 시간보다 늦어야 합니다'); + }); + + it('22:00-02:00과 같이 자정을 넘는 시간 처리', () => { + const result = getTimeErrorMessage('22:00', '02:00'); + expect(result.startTimeError).toBeTruthy(); + expect(result.endTimeError).toBeTruthy(); + }); + + it('23:59-00:01과 같이 자정 경계를 넘는 시간 처리', () => { + const result = getTimeErrorMessage('23:59', '00:01'); + expect(result.startTimeError).toBeTruthy(); + expect(result.endTimeError).toBeTruthy(); + }); + + it('23:45-01:15와 같은 야간 이벤트 시간 처리', () => { + const result = getTimeErrorMessage('23:45', '01:15'); + expect(result.startTimeError).toBeTruthy(); + expect(result.endTimeError).toBeTruthy(); + }); + + it('21:30-03:30과 같은 긴 야간 이벤트 시간 처리', () => { + const result = getTimeErrorMessage('21:30', '03:30'); + expect(result.startTimeError).toBeTruthy(); + expect(result.endTimeError).toBeTruthy(); + }); + + it('자정 정각을 포함한 경계값 - 23:00-01:00', () => { + const result = getTimeErrorMessage('23:00', '01:00'); + expect(result.startTimeError).toBeTruthy(); + expect(result.endTimeError).toBeTruthy(); + }); + + it('자정을 넘지 않는 늦은 시간은 정상 처리 - 22:00-23:59', () => { + const result = getTimeErrorMessage('22:00', '23:59'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('자정을 넘지 않는 새벽 시간은 정상 처리 - 00:00-05:00', () => { + const result = getTimeErrorMessage('00:00', '05:00'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + }); + + describe('실제 사용 시나리오', () => { + it('일반적인 회의 시간', () => { + expect(getTimeErrorMessage('14:00', '15:00').startTimeError).toBeNull(); + expect(getTimeErrorMessage('14:00', '16:00').startTimeError).toBeNull(); + expect(getTimeErrorMessage('09:00', '17:00').startTimeError).toBeNull(); + }); + + it('짧은 약속 시간', () => { + expect(getTimeErrorMessage('12:00', '12:30').startTimeError).toBeNull(); + expect(getTimeErrorMessage('15:30', '16:00').startTimeError).toBeNull(); + }); + + it('긴 이벤트 시간', () => { + expect(getTimeErrorMessage('09:00', '18:00').startTimeError).toBeNull(); + expect(getTimeErrorMessage('10:00', '22:00').startTimeError).toBeNull(); + }); + + it('사용자가 실수하기 쉬운 시간', () => { + expect(getTimeErrorMessage('14:00', '02:00')).toBeTruthy(); // PM 2시와 AM 2시 혼동 + expect(getTimeErrorMessage('10:30', '10:00')).toBeTruthy(); // 30분과 00분 혼동 + }); + }); + }); +}); From 9b0f4a48e83147a6fc8beae4b4a121ca1736bf19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 22 Aug 2025 09:55:23 +0900 Subject: [PATCH 26/27] =?UTF-8?q?feat:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=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__/CalendarView.test.tsx | 124 ++++++++++++++ src/__tests__/EventList.test.tsx | 158 ++++++++++++++++++ src/__tests__/NotificationToast.test.tsx | 151 +++++++++++++++++ src/__tests__/OverlapDialog.test.tsx | 141 ++++++++++++++++ .../components/advanced.CalendarView.spec.tsx | 94 +++++++++++ .../components/advanced.EventForm.spec.tsx | 149 +++++++++++++++++ .../components/advanced.EventList.spec.tsx | 112 +++++++++++++ 7 files changed, 929 insertions(+) create mode 100644 src/__tests__/CalendarView.test.tsx create mode 100644 src/__tests__/EventList.test.tsx create mode 100644 src/__tests__/NotificationToast.test.tsx create mode 100644 src/__tests__/OverlapDialog.test.tsx create mode 100644 src/__tests__/components/advanced.CalendarView.spec.tsx create mode 100644 src/__tests__/components/advanced.EventForm.spec.tsx create mode 100644 src/__tests__/components/advanced.EventList.spec.tsx diff --git a/src/__tests__/CalendarView.test.tsx b/src/__tests__/CalendarView.test.tsx new file mode 100644 index 00000000..5ca9fc24 --- /dev/null +++ b/src/__tests__/CalendarView.test.tsx @@ -0,0 +1,124 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, beforeEach, it } from 'vitest'; + +import { CalendarView } from '../components/CalendarView'; +import { Event } from '../types'; +import { createMockEvent, createMockHolidays, TEST_DATES } from './fixtures/mockData'; + +const mockEvents: Event[] = [createMockEvent()]; + +const defaultProps = { + currentDate: new Date(TEST_DATES.DEFAULT_DATE), + events: mockEvents, + holidays: createMockHolidays(), + notifiedEvents: [], +}; + +describe('CalendarView 컴포넌트', () => { + beforeEach(() => { + // 각 테스트 전에 모킹 초기화 + }); + + describe('주간 뷰 렌더링', () => { + it('주간 뷰가 올바르게 렌더링된다', () => { + render(); + + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + expect(screen.getByText('2024년 7월 3주')).toBeInTheDocument(); + }); + + it('주간 뷰에서 요일 헤더가 표시된다', () => { + render(); + + const expectedWeekDays = ['일', '월', '화', '수', '목', '금', '토']; + expectedWeekDays.forEach((day) => { + expect(screen.getByText(day)).toBeInTheDocument(); + }); + }); + + it('주간 뷰에서 이벤트가 올바른 날짜에 표시된다', () => { + render(); + + expect(screen.getByText('테스트 이벤트')).toBeInTheDocument(); + }); + + it('알림된 이벤트가 notified 스타일로 표시된다', () => { + render(); + + const eventElement = screen.getByText('테스트 이벤트'); + expect(eventElement).toBeInTheDocument(); + // 알림된 이벤트는 존재하는 것만 확인 (스타일은 상위 컴포넌트에서 처리) + }); + }); + + describe('월간 뷰 렌더링', () => { + it('월간 뷰가 올바르게 렌더링된다', () => { + render(); + + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + expect(screen.getByText('2024년 7월')).toBeInTheDocument(); + }); + + it('월간 뷰에서 공휴일이 표시된다', () => { + render(); + + expect(screen.getByText('광복절')).toBeInTheDocument(); + }); + + it('월간 뷰에서 이벤트가 올바른 날짜에 표시된다', () => { + render(); + + expect(screen.getByText('테스트 이벤트')).toBeInTheDocument(); + }); + + it('월간 뷰에서 달력 셀이 적절한 수만큼 렌더링된다', () => { + render(); + + const allCells = screen.getAllByRole('cell'); + const expectedMinCells = 31; // 7월 최소 일수 + expect(allCells.length).toBeGreaterThanOrEqual(expectedMinCells); + }); + }); + + describe('이벤트 표시', () => { + it('여러 이벤트가 같은 날짜에 있을 때 모두 표시된다', () => { + const multipleEvents = [ + createMockEvent(), + createMockEvent({ + id: '2', + title: '두 번째 이벤트', + startTime: '14:00', + endTime: '15:00', + category: '개인', + }), + ]; + + render(); + + expect(screen.getByText('테스트 이벤트')).toBeInTheDocument(); + expect(screen.getByText('두 번째 이벤트')).toBeInTheDocument(); + }); + + it('이벤트가 없는 날짜는 빈 상태로 표시된다', () => { + render(); + + expect(screen.queryByText('테스트 이벤트')).not.toBeInTheDocument(); + }); + }); + + describe('날짜 경계 테스트', () => { + it('월 경계를 넘나드는 주간 뷰가 올바르게 표시된다', () => { + const monthEndDate = new Date('2024-07-31'); + render(); + + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + + it('연도 경계를 넘나드는 월간 뷰가 올바르게 표시된다', () => { + const yearEndDate = new Date('2024-12-31'); + render(); + + expect(screen.getByText('2024년 12월')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/EventList.test.tsx b/src/__tests__/EventList.test.tsx new file mode 100644 index 00000000..4d8c4019 --- /dev/null +++ b/src/__tests__/EventList.test.tsx @@ -0,0 +1,158 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { EventList } from '../components/EventList'; +import { Event } from '../types'; +import { createMockEvents } from './fixtures/mockData'; + +const mockEvents: Event[] = createMockEvents(); + +const defaultProps = { + events: mockEvents, + notifiedEvents: [], + searchTerm: '', + onSearchChange: vi.fn(), + onEditEvent: vi.fn(), + onDeleteEvent: vi.fn(), +}; + +describe('EventList 컴포넌트', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('기본 렌더링', () => { + test('이벤트 목록이 올바르게 렌더링된다', () => { + render(); + + expect(screen.getByTestId('event-list')).toBeInTheDocument(); + expect(screen.getByLabelText('일정 검색')).toBeInTheDocument(); + }); + + test('모든 이벤트가 표시된다', () => { + render(); + + expect(screen.getByText('회의')).toBeInTheDocument(); + expect(screen.getByText('점심 약속')).toBeInTheDocument(); + }); + + test('이벤트 상세 정보가 표시된다', () => { + render(); + + // 첫 번째 이벤트 정보 + expect(screen.getByText('2024-07-15')).toBeInTheDocument(); + expect(screen.getByText('09:00 - 10:00')).toBeInTheDocument(); + expect(screen.getByText('팀 회의')).toBeInTheDocument(); + expect(screen.getByText('회의실')).toBeInTheDocument(); + expect(screen.getByText('카테고리: 업무')).toBeInTheDocument(); + }); + }); + + describe('검색 기능', () => { + test('검색 입력 필드가 올바르게 작동한다', () => { + render(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + fireEvent.change(searchInput, { target: { value: '회의' } }); + + expect(defaultProps.onSearchChange).toHaveBeenCalledWith('회의'); + }); + + test('검색어가 표시된다', () => { + render(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + expect(searchInput).toHaveValue('회의'); + }); + }); + + describe('이벤트 액션', () => { + test('편집 버튼이 올바르게 작동한다', () => { + render(); + + const editButtons = screen.getAllByLabelText('Edit event'); + fireEvent.click(editButtons[0]); + + expect(defaultProps.onEditEvent).toHaveBeenCalledWith(mockEvents[0]); + }); + + test('삭제 버튼이 올바르게 작동한다', () => { + render(); + + const deleteButtons = screen.getAllByLabelText('Delete event'); + fireEvent.click(deleteButtons[0]); + + expect(defaultProps.onDeleteEvent).toHaveBeenCalledWith('1'); + }); + }); + + describe('알림 상태', () => { + test('알림된 이벤트가 특별한 스타일로 표시된다', () => { + render(); + + const eventTitle = screen.getByText('회의'); + expect(eventTitle).toBeInTheDocument(); + }); + + test('알림된 이벤트에 알림 아이콘이 표시된다', () => { + render(); + + const notificationIcons = screen.getAllByTestId('NotificationsIcon'); + expect(notificationIcons).toHaveLength(1); + }); + + test('알림되지 않은 이벤트는 기본 스타일로 표시된다', () => { + render(); + + const eventTitle = screen.getByText('회의'); + expect(eventTitle).toBeInTheDocument(); + }); + }); + + describe('반복 일정 표시', () => { + test('반복 일정 정보가 올바르게 표시된다', () => { + render(); + + expect(screen.getByText('반복: 1주마다 (종료: 2024-12-31)')).toBeInTheDocument(); + }); + + test('반복하지 않는 일정은 반복 정보가 표시되지 않는다', () => { + const nonRepeatEvents = [mockEvents[0]]; // 첫 번째 이벤트만 (반복 없음) + render(); + + expect(screen.queryByText(/반복:/)).not.toBeInTheDocument(); + }); + }); + + describe('알림 시간 표시', () => { + test('알림 시간이 올바르게 표시된다', () => { + render(); + + expect(screen.getByText('알림: 10분 전')).toBeInTheDocument(); + expect(screen.getByText('알림: 1시간 전')).toBeInTheDocument(); + }); + }); + + describe('빈 상태', () => { + test('이벤트가 없을 때 적절한 메시지가 표시된다', () => { + render(); + + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + }); + + describe('접근성', () => { + test('편집 및 삭제 버튼에 적절한 aria-label이 있다', () => { + render(); + + expect(screen.getAllByLabelText('Edit event')).toHaveLength(2); + expect(screen.getAllByLabelText('Delete event')).toHaveLength(2); + }); + + test('검색 입력 필드에 적절한 레이블이 있다', () => { + render(); + + expect(screen.getByLabelText('일정 검색')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/NotificationToast.test.tsx b/src/__tests__/NotificationToast.test.tsx new file mode 100644 index 00000000..e65b3ce4 --- /dev/null +++ b/src/__tests__/NotificationToast.test.tsx @@ -0,0 +1,151 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, test, expect, vi } from 'vitest'; + +import { NotificationToast } from '../components/NotificationToast'; + +const mockNotifications = [ + { id: '1', message: '첫 번째 알림' }, + { id: '2', message: '두 번째 알림' }, +]; + +const defaultProps = { + notifications: mockNotifications, + onRemoveNotification: vi.fn(), +}; + +describe('NotificationToast 컴포넌트', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('기본 렌더링', () => { + test('알림이 있을 때 올바르게 렌더링된다', () => { + render(); + + expect(screen.getByText('첫 번째 알림')).toBeInTheDocument(); + expect(screen.getByText('두 번째 알림')).toBeInTheDocument(); + }); + + test('알림이 없을 때 아무것도 렌더링하지 않는다', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + test('알림들이 올바른 순서로 표시된다', () => { + render(); + + const alerts = screen.getAllByRole('alert'); + expect(alerts).toHaveLength(2); + + // 첫 번째 알림이 먼저 나타나야 함 + expect(alerts[0]).toHaveTextContent('첫 번째 알림'); + expect(alerts[1]).toHaveTextContent('두 번째 알림'); + }); + }); + + describe('닫기 기능', () => { + test('닫기 버튼을 클릭하면 onRemoveNotification이 올바른 인덱스와 함께 호출된다', () => { + render(); + + const closeButtons = screen.getAllByRole('button'); + fireEvent.click(closeButtons[0]); + + expect(defaultProps.onRemoveNotification).toHaveBeenCalledWith(0); + }); + + test('두 번째 알림의 닫기 버튼을 클릭하면 인덱스 1로 호출된다', () => { + render(); + + const closeButtons = screen.getAllByRole('button'); + fireEvent.click(closeButtons[1]); + + expect(defaultProps.onRemoveNotification).toHaveBeenCalledWith(1); + }); + + test('모든 알림에 닫기 버튼이 있다', () => { + render(); + + const closeButtons = screen.getAllByRole('button'); + expect(closeButtons).toHaveLength(2); + }); + }); + + describe('스타일링', () => { + test('알림이 고정 위치에 표시된다', () => { + const { container } = render(); + + const stackElement = container.firstChild as HTMLElement; + expect(stackElement).toHaveStyle({ + position: 'fixed', + top: '16px', + right: '16px', + }); + }); + + test('알림들이 세로로 정렬된다', () => { + render(); + + const alerts = screen.getAllByRole('alert'); + expect(alerts).toHaveLength(2); + }); + }); + + describe('접근성', () => { + test('각 알림이 alert role을 가진다', () => { + render(); + + const alerts = screen.getAllByRole('alert'); + expect(alerts).toHaveLength(2); + }); + + test('닫기 버튼들이 접근 가능하다', () => { + render(); + + const closeButtons = screen.getAllByRole('button'); + closeButtons.forEach((button) => { + expect(button).toBeInTheDocument(); + }); + }); + }); + + describe('경계 케이스', () => { + test('단일 알림이 올바르게 표시된다', () => { + const singleNotification = [{ id: '1', message: '단일 알림' }]; + render(); + + expect(screen.getByText('단일 알림')).toBeInTheDocument(); + expect(screen.getAllByRole('alert')).toHaveLength(1); + }); + + test('매우 긴 메시지도 올바르게 표시된다', () => { + const longMessage = + '매우 긴 알림 메시지입니다. 이 메시지는 일반적인 알림보다 훨씬 길어서 레이아웃에 어떤 영향을 주는지 확인하기 위한 테스트입니다.'; + const longNotification = [{ id: '1', message: longMessage }]; + render(); + + expect(screen.getByText(longMessage)).toBeInTheDocument(); + }); + + test('빈 메시지가 있어도 정상적으로 처리된다', () => { + const emptyMessage = [{ id: '1', message: '' }]; + render(); + + expect(screen.getAllByRole('alert')).toHaveLength(1); + }); + + test('많은 수의 알림도 올바르게 표시된다', () => { + const manyNotifications = Array.from({ length: 5 }, (_, i) => ({ + id: `${i + 1}`, + message: `알림 ${i + 1}`, + })); + + render(); + + expect(screen.getAllByRole('alert')).toHaveLength(5); + manyNotifications.forEach((notification) => { + expect(screen.getByText(notification.message)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/__tests__/OverlapDialog.test.tsx b/src/__tests__/OverlapDialog.test.tsx new file mode 100644 index 00000000..a619e484 --- /dev/null +++ b/src/__tests__/OverlapDialog.test.tsx @@ -0,0 +1,141 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, test, expect, vi } from 'vitest'; + +import { OverlapDialog } from '../components/OverlapDialog'; +import { Event } from '../types'; + +const mockOverlappingEvents: Event[] = [ + { + id: '1', + title: '기존 회의', + date: '2024-07-15', + startTime: '10:00', + endTime: '11:00', + description: '팀 회의', + location: '회의실', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2024-07-15', + startTime: '10:30', + endTime: '11:30', + description: '친구와 점심', + location: '레스토랑', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, +]; + +const defaultProps = { + open: true, + overlappingEvents: mockOverlappingEvents, + onClose: vi.fn(), + onConfirm: vi.fn(), +}; + +describe('OverlapDialog 컴포넌트', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('기본 렌더링', () => { + test('다이얼로그가 열린 상태일 때 올바르게 렌더링된다', () => { + render(); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + expect(screen.getByText(/다음 일정과 겹칩니다/)).toBeInTheDocument(); + expect(screen.getByText(/계속 진행하시겠습니까/)).toBeInTheDocument(); + }); + + test('다이얼로그가 닫힌 상태일 때 렌더링되지 않는다', () => { + render(); + + expect(screen.queryByText('일정 겹침 경고')).not.toBeInTheDocument(); + }); + + test('겹치는 일정 정보가 올바르게 표시된다', () => { + render(); + + expect(screen.getByText('기존 회의 (2024-07-15 10:00-11:00)')).toBeInTheDocument(); + expect(screen.getByText('점심 약속 (2024-07-15 10:30-11:30)')).toBeInTheDocument(); + }); + }); + + describe('버튼 액션', () => { + test('취소 버튼을 클릭하면 onClose가 호출된다', () => { + render(); + + const cancelButton = screen.getByText('취소'); + fireEvent.click(cancelButton); + + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + test('계속 진행 버튼을 클릭하면 onConfirm이 호출된다', () => { + render(); + + const confirmButton = screen.getByText('계속 진행'); + fireEvent.click(confirmButton); + + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); + }); + + test('다이얼로그 배경을 클릭하면 onClose가 호출된다', () => { + render(); + + // MUI Dialog의 backdrop 클릭 시뮬레이션 + const dialog = screen.getByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'Escape' }); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + }); + + describe('접근성', () => { + test('다이얼로그에 적절한 role이 설정되어 있다', () => { + render(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + test('버튼들이 접근 가능하다', () => { + render(); + + expect(screen.getByRole('button', { name: '취소' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '계속 진행' })).toBeInTheDocument(); + }); + }); + + describe('경계 케이스', () => { + test('겹치는 일정이 없을 때도 정상적으로 렌더링된다', () => { + render(); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + expect(screen.getByText(/다음 일정과 겹칩니다/)).toBeInTheDocument(); + }); + + test('겹치는 일정이 1개일 때 올바르게 표시된다', () => { + const singleEvent = [mockOverlappingEvents[0]]; + render(); + + expect(screen.getByText('기존 회의 (2024-07-15 10:00-11:00)')).toBeInTheDocument(); + expect(screen.queryByText('점심 약속')).not.toBeInTheDocument(); + }); + + test('매우 긴 제목의 일정도 올바르게 표시된다', () => { + const longTitleEvent = { + ...mockOverlappingEvents[0], + title: '매우 긴 제목을 가진 일정이 있을 때도 올바르게 표시되는지 확인하는 테스트', + }; + + render(); + + expect(screen.getByText(/매우 긴 제목을 가진 일정이 있을 때도/)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/components/advanced.CalendarView.spec.tsx b/src/__tests__/components/advanced.CalendarView.spec.tsx new file mode 100644 index 00000000..ca0cff7b --- /dev/null +++ b/src/__tests__/components/advanced.CalendarView.spec.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +import { CalendarView } from '../../components/CalendarView'; +import { server } from '../../setupTests'; +import { createMockEvent, createMockHolidays, TEST_DATES } from '../fixtures/mockData'; + +describe('CalendarView MSW API 테스트', () => { + const mockEvents = [createMockEvent()]; + + const defaultProps = { + currentDate: new Date(TEST_DATES.DEFAULT_DATE), + events: mockEvents, + holidays: createMockHolidays(), + notifiedEvents: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('공휴일 API 성공 시 공휴일이 표시된다', async () => { + server.use( + http.get('/api/holidays', () => { + return HttpResponse.json({ + success: true, + data: createMockHolidays(), + }); + }) + ); + + render(); + + expect(screen.getByText('광복절')).toBeInTheDocument(); + }); + + it('공휴일 API 실패 시 빈 공휴일로 렌더링된다', async () => { + server.use( + http.get('/api/holidays', () => { + return HttpResponse.json({ error: 'Failed to fetch holidays' }, { status: 500 }); + }) + ); + + render(); + + expect(screen.queryByText('광복절')).not.toBeInTheDocument(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('공휴일 API 네트워크 에러 시 적절히 처리된다', async () => { + server.use( + http.get('/api/holidays', () => { + return HttpResponse.json({ error: 'Network Error' }, { status: 503 }); + }) + ); + + render(); + + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('공휴일 API 타임아웃 시 달력이 정상 렌더링된다', async () => { + server.use( + http.get('/api/holidays', () => { + return HttpResponse.json({ error: 'Request timeout' }, { status: 408 }); + }) + ); + + render(); + + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + expect(screen.getByText('2024년 7월')).toBeInTheDocument(); + }); + + it('주간 뷰에서도 이벤트가 정상적으로 표시된다', async () => { + render(); + + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + expect(screen.getByText('테스트 이벤트')).toBeInTheDocument(); + }); + + it('알림된 이벤트가 주간 뷰에서 표시된다', async () => { + render(); + + expect(screen.getByText('테스트 이벤트')).toBeInTheDocument(); + }); + + it('빈 이벤트 배열로도 달력이 정상 렌더링된다', async () => { + render(); + + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + expect(screen.queryByText('테스트 이벤트')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/advanced.EventForm.spec.tsx b/src/__tests__/components/advanced.EventForm.spec.tsx new file mode 100644 index 00000000..8eb4e4b4 --- /dev/null +++ b/src/__tests__/components/advanced.EventForm.spec.tsx @@ -0,0 +1,149 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { SnackbarProvider } from 'notistack'; + +import { EventForm } from '../../components/EventForm'; +import { useEventForm } from '../../hooks/useEventForm'; +import { server } from '../../setupTests'; +import { createMockEvent, createMockEvents } from '../fixtures/mockData'; + +const TestEventForm = ({ onSubmit = vi.fn(), onOverlapDetected = vi.fn() }) => { + const eventFormHook = useEventForm(); + const mockEvents = createMockEvents(); + + return ( + + + + ); +}; + +describe('EventForm MSW API 테스트', () => { + const mockOnSubmit = vi.fn(); + const mockOnOverlapDetected = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('저장 성공 시 성공 토스트가 표시된다', async () => { + server.use( + http.post('/api/events', () => { + return HttpResponse.json({ + success: true, + data: createMockEvent({ title: '새로운 이벤트' }), + }); + }) + ); + + render(); + + // 폼 입력 + await act(async () => { + fireEvent.change(screen.getByLabelText('제목'), { target: { value: '새로운 이벤트' } }); + fireEvent.change(screen.getByLabelText('날짜'), { target: { value: '2024-07-20' } }); + fireEvent.change(screen.getByLabelText('시작 시간'), { target: { value: '10:00' } }); + fireEvent.change(screen.getByLabelText('종료 시간'), { target: { value: '11:00' } }); + }); + + // 폼 제출 + await act(async () => { + fireEvent.click(screen.getByTestId('event-submit-button')); + }); + + expect(mockOnSubmit).toHaveBeenCalled(); + }); + + it('저장 실패 시 에러 토스트가 표시된다', async () => { + server.use( + http.post('/api/events', () => { + return HttpResponse.json({ error: 'Save failed' }, { status: 500 }); + }) + ); + + render(); + + await act(async () => { + fireEvent.change(screen.getByLabelText('제목'), { target: { value: '실패할 이벤트' } }); + fireEvent.change(screen.getByLabelText('날짜'), { target: { value: '2024-07-20' } }); + fireEvent.change(screen.getByLabelText('시작 시간'), { target: { value: '10:00' } }); + fireEvent.change(screen.getByLabelText('종료 시간'), { target: { value: '11:00' } }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('event-submit-button')); + }); + + expect(mockOnSubmit).toHaveBeenCalled(); + }); + + it('수정 실패 시 에러 처리가 된다', async () => { + server.use( + http.put('/api/events/:id', () => { + return HttpResponse.json({ error: 'Update failed' }, { status: 404 }); + }) + ); + + render(); + + await act(async () => { + fireEvent.change(screen.getByLabelText('제목'), { target: { value: '수정할 이벤트' } }); + fireEvent.change(screen.getByLabelText('날짜'), { target: { value: '2024-07-20' } }); + fireEvent.change(screen.getByLabelText('시작 시간'), { target: { value: '10:00' } }); + fireEvent.change(screen.getByLabelText('종료 시간'), { target: { value: '11:00' } }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('event-submit-button')); + }); + + expect(mockOnSubmit).toHaveBeenCalled(); + }); + + it('네트워크 타임아웃 시 적절한 에러 처리가 된다', async () => { + server.use( + http.post('/api/events', () => { + return HttpResponse.json({ error: 'Timeout' }, { status: 408 }); + }) + ); + + render(); + + await act(async () => { + fireEvent.change(screen.getByLabelText('제목'), { target: { value: '타임아웃 이벤트' } }); + fireEvent.change(screen.getByLabelText('날짜'), { target: { value: '2024-07-20' } }); + fireEvent.change(screen.getByLabelText('시작 시간'), { target: { value: '10:00' } }); + fireEvent.change(screen.getByLabelText('종료 시간'), { target: { value: '11:00' } }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('event-submit-button')); + }); + + expect(mockOnSubmit).toHaveBeenCalled(); + }); + + it('겹치는 이벤트가 있을 때 onOverlapDetected 콜백이 호출된다', async () => { + render(); + + // 기존 mockEvents와 겹치는 시간대로 설정 (09:00-10:00과 겹침) + await act(async () => { + fireEvent.change(screen.getByLabelText('제목'), { target: { value: '겹치는 이벤트' } }); + fireEvent.change(screen.getByLabelText('날짜'), { target: { value: '2024-07-15' } }); // 기존 이벤트와 같은 날 + fireEvent.change(screen.getByLabelText('시작 시간'), { target: { value: '09:30' } }); // 기존 09:00-10:00과 겹침 + fireEvent.change(screen.getByLabelText('종료 시간'), { target: { value: '10:30' } }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('event-submit-button')); + }); + + // 겹치는 이벤트 감지 시 onOverlapDetected가 호출되어야 함 + expect(mockOnOverlapDetected).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/components/advanced.EventList.spec.tsx b/src/__tests__/components/advanced.EventList.spec.tsx new file mode 100644 index 00000000..2184479b --- /dev/null +++ b/src/__tests__/components/advanced.EventList.spec.tsx @@ -0,0 +1,112 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +import { EventList } from '../../components/EventList'; +import { server } from '../../setupTests'; +import { createMockEvents } from '../fixtures/mockData'; + +const mockOnSearchChange = vi.fn(); +const mockOnEditEvent = vi.fn(); +const mockOnDeleteEvent = vi.fn(); + +describe('EventList MSW API 테스트', () => { + const mockEvents = createMockEvents(); + + const defaultProps = { + events: mockEvents, + notifiedEvents: [], + searchTerm: '', + onSearchChange: mockOnSearchChange, + onEditEvent: mockOnEditEvent, + onDeleteEvent: mockOnDeleteEvent, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('삭제 성공 시 onDeleteEvent 콜백이 호출된다', async () => { + server.use( + http.delete('/api/events/:id', () => { + return HttpResponse.json({ success: true }); + }) + ); + + render(); + + const deleteButtons = screen.getAllByLabelText('Delete event'); + fireEvent.click(deleteButtons[0]); + + expect(mockOnDeleteEvent).toHaveBeenCalledWith('1'); + }); + + it('삭제 실패 시 에러 처리가 된다', async () => { + server.use( + http.delete('/api/events/:id', () => { + return HttpResponse.json({ error: 'Delete failed' }, { status: 500 }); + }) + ); + + render(); + + const deleteButtons = screen.getAllByLabelText('Delete event'); + fireEvent.click(deleteButtons[0]); + + expect(mockOnDeleteEvent).toHaveBeenCalledWith('1'); + }); + + it('존재하지 않는 이벤트 삭제 시 404 에러 처리가 된다', async () => { + server.use( + http.delete('/api/events/:id', () => { + return HttpResponse.json({ error: 'Event not found' }, { status: 404 }); + }) + ); + + render(); + + const deleteButtons = screen.getAllByLabelText('Delete event'); + fireEvent.click(deleteButtons[0]); + + expect(mockOnDeleteEvent).toHaveBeenCalledWith('1'); + }); + + it('네트워크 에러 시 적절한 에러 처리가 된다', async () => { + server.use( + http.delete('/api/events/:id', () => { + return HttpResponse.json({ error: 'Network Error' }, { status: 503 }); + }) + ); + + render(); + + const deleteButtons = screen.getAllByLabelText('Delete event'); + fireEvent.click(deleteButtons[0]); + + expect(mockOnDeleteEvent).toHaveBeenCalledWith('1'); + }); + + it('편집 버튼 클릭 시 onEditEvent 콜백이 호출된다', async () => { + render(); + + const editButtons = screen.getAllByLabelText('Edit event'); + fireEvent.click(editButtons[0]); + + expect(mockOnEditEvent).toHaveBeenCalledWith(mockEvents[0]); + }); + + it('검색 기능이 정상 작동한다', async () => { + render(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + fireEvent.change(searchInput, { target: { value: '회의' } }); + + expect(mockOnSearchChange).toHaveBeenCalledWith('회의'); + }); + + it('알림된 이벤트에 알림 아이콘이 표시된다', async () => { + render(); + + const notificationIcons = screen.getAllByTestId('NotificationsIcon'); + expect(notificationIcons).toHaveLength(1); + }); +}); From 95fd0be2f69ac9ecbdb67c03a424148b1fa523e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 22 Aug 2025 09:55:29 +0900 Subject: [PATCH 27/27] =?UTF-8?q?feat:=20=ED=9B=85=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/fixtures/mockData.ts | 98 ++++++ .../hooks/advanced.useEventForm.spec.ts | 317 ++++++++++++++++++ src/hooks/useEventOperations.ts | 6 +- 3 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/fixtures/mockData.ts create mode 100644 src/__tests__/hooks/advanced.useEventForm.spec.ts diff --git a/src/__tests__/fixtures/mockData.ts b/src/__tests__/fixtures/mockData.ts new file mode 100644 index 00000000..4873c7b2 --- /dev/null +++ b/src/__tests__/fixtures/mockData.ts @@ -0,0 +1,98 @@ +import { Event } from '../../types'; + +// 공통 테스트 데이터 상수 +export const TEST_DATES = { + DEFAULT_DATE: '2024-07-15', + WEEK_START: '2024-07-14', // 일요일 + MONTH_JULY: '2024-07-01', + YEAR_END: '2024-12-31', +} as const; + +export const TEST_TIMES = { + MORNING_START: '09:00', + MORNING_END: '10:00', + AFTERNOON_START: '14:00', + AFTERNOON_END: '15:00', + OVERLAP_START: '10:30', + OVERLAP_END: '11:30', +} as const; + +export const TEST_CATEGORIES = { + WORK: '업무', + PERSONAL: '개인', + FAMILY: '가족', + OTHER: '기타', +} as const; + +// 기본 테스트 이벤트 +export const createMockEvent = (overrides: Partial = {}): Event => ({ + id: '1', + title: '테스트 이벤트', + date: TEST_DATES.DEFAULT_DATE, + startTime: TEST_TIMES.MORNING_START, + endTime: TEST_TIMES.MORNING_END, + description: '테스트 설명', + location: '테스트 장소', + category: TEST_CATEGORIES.WORK, + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + ...overrides, +}); + +// 여러 이벤트 데이터 +export const createMockEvents = (): Event[] => [ + createMockEvent({ + id: '1', + title: '회의', + description: '팀 회의', + location: '회의실', + category: TEST_CATEGORIES.WORK, + }), + createMockEvent({ + id: '2', + title: '점심 약속', + date: '2024-07-16', + startTime: '12:00', + endTime: '13:00', + description: '친구와 점심', + location: '레스토랑', + category: TEST_CATEGORIES.PERSONAL, + repeat: { type: 'weekly', interval: 1, endDate: TEST_DATES.YEAR_END }, + notificationTime: 60, + }), +]; + +// 겹치는 이벤트 데이터 +export const createOverlappingEvents = (): Event[] => [ + createMockEvent({ + id: '1', + title: '기존 회의', + startTime: TEST_TIMES.MORNING_START, + endTime: TEST_TIMES.MORNING_END, + }), + createMockEvent({ + id: '2', + title: '점심 약속', + startTime: TEST_TIMES.OVERLAP_START, + endTime: TEST_TIMES.OVERLAP_END, + }), +]; + +// 공휴일 데이터 +export const createMockHolidays = () => ({ + [TEST_DATES.DEFAULT_DATE]: '광복절', + '2025-01-01': '신정', +}); + +// 알림 데이터 +export const createMockNotifications = () => [ + { id: '1', message: '회의가 10분 후 시작됩니다.', type: 'info' as const }, + { id: '2', message: '점심 약속을 잊지 마세요!', type: 'warning' as const }, +]; + +// API 응답 형식 +export const createApiResponse = (data: T, success = true) => ({ + success, + data, + message: success ? 'Success' : 'Error occurred', +}); diff --git a/src/__tests__/hooks/advanced.useEventForm.spec.ts b/src/__tests__/hooks/advanced.useEventForm.spec.ts new file mode 100644 index 00000000..a4e1c782 --- /dev/null +++ b/src/__tests__/hooks/advanced.useEventForm.spec.ts @@ -0,0 +1,317 @@ +import { act, renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { useEventForm } from '../../hooks/useEventForm'; + +describe('useEventForm 훅', () => { + describe('초기 상태', () => { + it('모든 필드가 기본값으로 초기화된다', () => { + const { result } = renderHook(() => useEventForm()); + + expect(result.current.title).toBe(''); + expect(result.current.date).toBe(''); + expect(result.current.startTime).toBe(''); + expect(result.current.endTime).toBe(''); + expect(result.current.description).toBe(''); + expect(result.current.location).toBe(''); + expect(result.current.category).toBe('업무'); + expect(result.current.isRepeating).toBe(true); + expect(result.current.repeatType).toBe('none'); + expect(result.current.repeatInterval).toBe(1); + expect(result.current.repeatEndDate).toBe(''); + expect(result.current.notificationTime).toBe(10); + expect(result.current.editingEvent).toBeNull(); + }); + + it('에러 상태가 초기에는 비어있다', () => { + const { result } = renderHook(() => useEventForm()); + + expect(result.current.startTimeError).toBeNull(); + expect(result.current.endTimeError).toBeNull(); + }); + }); + + describe('필드 업데이트', () => { + it('제목을 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.setTitle('새로운 일정'); + }); + + expect(result.current.title).toBe('새로운 일정'); + }); + + it('날짜를 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.setDate('2024-07-15'); + }); + + expect(result.current.date).toBe('2024-07-15'); + }); + + it('설명을 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.setDescription('테스트 설명'); + }); + + expect(result.current.description).toBe('테스트 설명'); + }); + + it('위치를 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.setLocation('회의실 A'); + }); + + expect(result.current.location).toBe('회의실 A'); + }); + + it('카테고리를 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.setCategory('개인'); + }); + + expect(result.current.category).toBe('개인'); + }); + + it('반복 설정을 변경할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.setIsRepeating(true); + }); + + expect(result.current.isRepeating).toBe(true); + }); + + it('알림 시간을 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.setNotificationTime(60); + }); + + expect(result.current.notificationTime).toBe(60); + }); + }); + + describe('시간 처리', () => { + it('시작 시간을 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.handleStartTimeChange({ + target: { value: '09:00' }, + } as React.ChangeEvent); + }); + + expect(result.current.startTime).toBe('09:00'); + }); + + it('종료 시간을 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.handleEndTimeChange({ + target: { value: '10:00' }, + } as React.ChangeEvent); + }); + + expect(result.current.endTime).toBe('10:00'); + }); + + it('시작 시간이 종료 시간보다 늦으면 에러가 설정된다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.handleStartTimeChange({ + target: { value: '10:00' }, + } as React.ChangeEvent); + }); + + act(() => { + result.current.handleEndTimeChange({ + target: { value: '09:00' }, + } as React.ChangeEvent); + }); + + expect(result.current.startTimeError).toBeTruthy(); + expect(result.current.endTimeError).toBeTruthy(); + }); + + it('올바른 시간 순서일 때 에러가 클리어된다', () => { + const { result } = renderHook(() => useEventForm()); + + // 먼저 잘못된 시간 설정 + act(() => { + result.current.handleStartTimeChange({ + target: { value: '10:00' }, + } as React.ChangeEvent); + result.current.handleEndTimeChange({ + target: { value: '09:00' }, + } as React.ChangeEvent); + }); + + // 올바른 시간으로 수정 + act(() => { + result.current.handleEndTimeChange({ + target: { value: '11:00' }, + } as React.ChangeEvent); + }); + + expect(result.current.startTimeError).toBeNull(); + expect(result.current.endTimeError).toBeNull(); + }); + }); + + describe('이벤트 편집', () => { + const mockEvent = { + id: '1', + title: '테스트 이벤트', + date: '2024-07-15', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '업무', + repeat: { type: 'none' as const, interval: 1 }, + notificationTime: 30, + }; + + it('기존 이벤트 편집 시 모든 필드가 설정된다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.editEvent(mockEvent); + }); + + expect(result.current.editingEvent).toEqual(mockEvent); + expect(result.current.title).toBe('테스트 이벤트'); + expect(result.current.date).toBe('2024-07-15'); + expect(result.current.startTime).toBe('09:00'); + expect(result.current.endTime).toBe('10:00'); + expect(result.current.description).toBe('테스트 설명'); + expect(result.current.location).toBe('테스트 장소'); + expect(result.current.category).toBe('업무'); + expect(result.current.notificationTime).toBe(30); + }); + + it('반복 일정 편집 시 반복 설정이 올바르게 설정된다', () => { + const repeatingEvent = { + ...mockEvent, + repeat: { type: 'weekly' as const, interval: 2, endDate: '2024-12-31' }, + }; + + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.editEvent(repeatingEvent); + }); + + expect(result.current.isRepeating).toBe(true); + expect(result.current.repeatType).toBe('weekly'); + expect(result.current.repeatInterval).toBe(2); + expect(result.current.repeatEndDate).toBe('2024-12-31'); + }); + }); + + describe('폼 초기화', () => { + it('resetForm 호출 시 모든 필드가 초기값으로 돌아간다', () => { + const { result } = renderHook(() => useEventForm()); + + // 필드들을 수정 + act(() => { + result.current.setTitle('테스트'); + result.current.setDate('2024-07-15'); + result.current.setDescription('설명'); + result.current.setLocation('장소'); + result.current.setCategory('개인'); + result.current.setIsRepeating(true); + result.current.setNotificationTime(60); + }); + + // 초기화 + act(() => { + result.current.resetForm(); + }); + + expect(result.current.title).toBe(''); + expect(result.current.date).toBe(''); + expect(result.current.description).toBe(''); + expect(result.current.location).toBe(''); + expect(result.current.category).toBe('업무'); + expect(result.current.isRepeating).toBe(false); + expect(result.current.notificationTime).toBe(10); + expect(result.current.editingEvent).toBeNull(); + }); + + it('resetForm 호출 시 에러도 클리어된다', () => { + const { result } = renderHook(() => useEventForm()); + + // 에러 상태 만들기 + act(() => { + result.current.handleStartTimeChange({ + target: { value: '10:00' }, + } as React.ChangeEvent); + result.current.handleEndTimeChange({ + target: { value: '09:00' }, + } as React.ChangeEvent); + }); + + // 초기화 + act(() => { + result.current.resetForm(); + }); + + expect(result.current.startTimeError).toBeNull(); + expect(result.current.endTimeError).toBeNull(); + expect(result.current.startTime).toBe(''); + expect(result.current.endTime).toBe(''); + }); + }); + + describe('경계값 테스트', () => { + it('빈 문자열들을 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.setTitle(''); + result.current.setDescription(''); + result.current.setLocation(''); + }); + + expect(result.current.title).toBe(''); + expect(result.current.description).toBe(''); + expect(result.current.location).toBe(''); + }); + + it('동일한 시작/종료 시간을 설정할 수 있다', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.handleStartTimeChange({ + target: { value: '10:00' }, + } as React.ChangeEvent); + }); + + act(() => { + result.current.handleEndTimeChange({ + target: { value: '10:00' }, + } as React.ChangeEvent); + }); + + expect(result.current.startTime).toBe('10:00'); + expect(result.current.endTime).toBe('10:00'); + expect(result.current.startTimeError).toBeTruthy(); + }); + }); +}); diff --git a/src/hooks/useEventOperations.ts b/src/hooks/useEventOperations.ts index 3216cc05..da389e16 100644 --- a/src/hooks/useEventOperations.ts +++ b/src/hooks/useEventOperations.ts @@ -24,7 +24,9 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { const saveEvent = async (eventData: Event | EventForm) => { try { let response; - if (editing) { + const isEditing = editing || !!(eventData as Event).id; + + if (isEditing) { response = await fetch(`/api/events/${(eventData as Event).id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -44,7 +46,7 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { await fetchEvents(); onSave?.(); - enqueueSnackbar(editing ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.', { + enqueueSnackbar(isEditing ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.', { variant: 'success', }); } catch (error) {