From eee4a5886a96d47e34518a66e167050f34868361 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Wed, 20 Aug 2025 14:07:20 +0900 Subject: [PATCH 01/43] first commit --- src/__mocks__/handlers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__mocks__/handlers.ts b/src/__mocks__/handlers.ts index 42d6d4b7..88cb1f8c 100644 --- a/src/__mocks__/handlers.ts +++ b/src/__mocks__/handlers.ts @@ -3,6 +3,7 @@ import { http, HttpResponse } from 'msw'; import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; import { Event } from '../types'; +// ! TODO: // ! HARD // ! 각 응답에 대한 MSW 핸들러를 작성해주세요. GET 요청은 이미 작성되어 있는 events json을 활용해주세요. export const handlers = [ From 9dd322ad4405c7a805e7e4c8834d895e32714dfb Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 14:02:10 +0900 Subject: [PATCH 02/43] chore: test push --- src/__tests__/hooks/easy.useSearch.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 80f57fa3..7691e944 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -1,3 +1,4 @@ +// test push import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; From 8a6f04c7a4617c211666bae1dac5bb0cac5c218a Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 15:01:07 +0900 Subject: [PATCH 03/43] =?UTF-8?q?feat:=20dateUtils=20=EC=9C=A0=EB=8B=9B=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/unit/easy.dateUtils.spec.ts | 521 +++++++++++++++++++--- 1 file changed, 462 insertions(+), 59 deletions(-) diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..015ef94f 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -12,105 +12,508 @@ import { } from '../../utils/dateUtils'; describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); - - it('4월은 30일 일수를 반환한다', () => {}); + it('1월은 31일 수를 반환한다', () => { + const year = 2025; + const month = 1; + const expected = 31; + + const result = getDaysInMonth(year, month); + expect(result).toBe(expected); + }); + + it('4월은 30일 일수를 반환한다', () => { + const year = 2025; + const month = 4; + const expected = 30; + + const result = getDaysInMonth(year, month); + + expect(result).toBe(expected); + }); + + describe('윤년 테스트', () => { + const leapYearTestCases = [ + { year: 2000, month: 2, expected: 29 }, // 400으로 나눠짐 → 윤년 + { year: 1900, month: 2, expected: 28 }, // 100으로 나눠짐 → 평년 + { year: 2004, month: 2, expected: 29 }, // 4로 나눠짐 → 윤년 + { year: 2100, month: 2, expected: 28 }, // 100으로 나눠짐 → 평년 + { year: 2024, month: 2, expected: 29 }, // 가까운 미래 윤년 + { year: 2023, month: 2, expected: 28 }, // 일반 평년 + ]; + + it.each(leapYearTestCases)( + 'year: $year, month: $month → $expected일을 반환한다', + ({ year, month, expected }) => { + const result = getDaysInMonth(year, month); + expect(result).toBe(expected); + } + ); + }); + + it('윤년의 2월에 대해 29일을 반환한다', () => { + const year = 2024; + const month = 2; + const expected = 29; + + const result = getDaysInMonth(year, month); + + expect(result).toBe(expected); + }); + + it('평년의 2월에 대해 28일을 반환한다', () => { + const year = 2025; + const month = 2; + const expected = 28; + + const result = getDaysInMonth(year, month); + + expect(result).toBe(expected); + }); + + it('유효하지 않은 월에 대해 적절히 처리한다', () => { + const year = 2025; + const month = 13; + const expected = 31; // 13월은 2026년 1월의 마지막 날을 반환 + + const result = getDaysInMonth(year, month); + + expect(result).toBe(expected); + }); +}); - it('윤년의 2월에 대해 29일을 반환한다', () => {}); +describe('getWeekDates', () => { + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = new Date('2025-07-16'); // 2025년 7월 16일 (수욜) + const expected = [ + new Date('2025-07-13'), // 일요일 + new Date('2025-07-14'), // 월요일 + new Date('2025-07-15'), // 화요일 + new Date('2025-07-16'), // 수요일 + new Date('2025-07-17'), // 목요일 + new Date('2025-07-18'), // 금요일 + new Date('2025-07-19'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = new Date('2025-07-14'); // 2025년 7월 14일 (월요일) + const expected = [ + new Date('2025-07-13'), // 일요일 (주의 시작) + new Date('2025-07-14'), // 월요일 + new Date('2025-07-15'), // 화요일 + new Date('2025-07-16'), // 수요일 + new Date('2025-07-17'), // 목요일 + new Date('2025-07-18'), // 금요일 + new Date('2025-07-19'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = new Date('2025-07-20'); // 2025년 7월 20일 (일요일) + const expected = [ + new Date('2025-07-20'), // 일요일 (주의 시작) + new Date('2025-07-21'), // 월요일 + new Date('2025-07-22'), // 화요일 + new Date('2025-07-23'), // 수요일 + new Date('2025-07-24'), // 목요일 + new Date('2025-07-25'), // 금요일 + new Date('2025-07-26'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + const date = new Date('2025-12-29'); // 2025년 12월 29일 (월요일) + const expected = [ + new Date('2025-12-28'), // 일요일 (주의 시작) + new Date('2025-12-29'), // 월요일 + new Date('2025-12-30'), // 화요일 + new Date('2025-12-31'), // 수요일 + new Date('2026-01-01'), // 목요일 (2026년 1월 1일) + new Date('2026-01-02'), // 금요일 + new Date('2026-01-03'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + const date = new Date('2025-01-01'); // 2025년 1월 1일 (수요일) + const expected = [ + new Date('2024-12-29'), // 일요일 (주의 시작, 2024년 12월 29일) + new Date('2024-12-30'), // 월요일 (2024년 12월 30일) + new Date('2024-12-31'), // 화요일 (2024년 12월 31일) + new Date('2025-01-01'), // 수요일 + new Date('2025-01-02'), // 목요일 + new Date('2025-01-03'), // 금요일 + new Date('2025-01-04'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + const date = new Date('2024-02-29'); // 2024년 2월 29일 (목요일) + const expected = [ + new Date('2024-02-25'), // 일요일 (주의 시작) + new Date('2024-02-26'), // 월요일 + new Date('2024-02-27'), // 화요일 + new Date('2024-02-28'), // 수요일 + new Date('2024-02-29'), // 목요일 + new Date('2024-03-01'), // 금요일 (3월 1일) + new Date('2024-03-02'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => { + const date = new Date('2025-07-31'); // 2025년 7월 31일 (목요일) + const expected = [ + new Date('2025-07-27'), // 일요일 (주의 시작) + new Date('2025-07-28'), // 월요일 + new Date('2025-07-29'), // 화요일 + new Date('2025-07-30'), // 수요일 + new Date('2025-07-31'), // 목요일 + new Date('2025-08-01'), // 금요일 (8월 1일) + new Date('2025-08-02'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); +}); - it('평년의 2월에 대해 28일을 반환한다', () => {}); +describe('getWeeksAtMonth', () => { + it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => { + const date = new Date('2025-07-01'); + const expected = [ + [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], + ]; + + const result = getWeeksAtMonth(date); + + expect(result).toEqual(expected); + expect(result).toHaveLength(5); + expect(result[0]).toHaveLength(7); + }); +}); - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); +describe('getEventsForDay', () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '테스트 이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '테스트 이벤트 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + description: '테스트 설명 2', + location: '테스트 장소 2', + category: '테스트2', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + ]; + + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + const date = 1; // 1일 + const expected = [mockEvents[0]]; + + const result = getEventsForDay(mockEvents, date); + + expect(result).toEqual(expected); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + const date = 3; // 3일 + const expected: Event[] = []; + + const result = getEventsForDay(mockEvents, date); + + expect(result).toEqual(expected); + }); + + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + const date = 0; // 0일 + const expected: Event[] = []; + + const result = getEventsForDay(mockEvents, date); + + expect(result).toEqual(expected); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + const date = 32; // 32일 + const expected: Event[] = []; + + const result = getEventsForDay(mockEvents, date); + + expect(result).toEqual(expected); + }); }); -describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); +describe('formatWeek', () => { + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-07-15'); // 2025년 7월 15일 + const expected = '2025년 7월 3주'; - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + const result = formatWeek(date); - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + expect(result).toBe(expected); + }); - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date(2025, 6, 1); // 2025년 7월 1일 + const expected = '2025년 7월 1주'; - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); + const result = formatWeek(date); - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); + expect(result).toBe(expected); + }); - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); -}); + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-07-31'); // 2025년 7월 31일 + const expected = '2025년 7월 5주'; -describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); -}); + const result = formatWeek(date); -describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); + expect(result).toBe(expected); + }); - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date(2025, 11, 29); // 2025년 12월 29일 + // formatWeek는 해당 날짜가 속한 주의 주차를 계산하므로 연도가 바뀌는 주에서는 다른 결과가 나올 수 있음 + const result = formatWeek(date); - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); + // 결과가 올바른 형식인지만 확인 + expect(result).toMatch(/^\d{4}년 \d{1,2}월 \d+주$/); + }); - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); -}); - -describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2024-02-29'); // 2024년 2월 29일 + const expected = '2024년 2월 5주'; - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); + const result = formatWeek(date); - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + expect(result).toBe(expected); + }); - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-02-28'); // 2025년 2월 28일 + const expected = '2025년 2월 4주'; - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + const result = formatWeek(date); - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + expect(result).toBe(expected); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + const date = new Date('2025-07-10'); + const expected = '2025년 7월'; + const result = formatMonth(date); + expect(result).toBe(expected); + }); + // 경계값 테스트 추가 + it("2026년 1월 1일을 '2026년 1월'로 반환한다", () => { + const date = new Date('2026-01-01'); + const expected = '2026년 1월'; + const result = formatMonth(date); + expect(result).toBe(expected); + }); + it("2026년 12월 31일을 '2026년 12월'로 반환한다", () => { + const date = new Date('2026-12-31'); + const expected = '2026년 12월'; + const result = formatMonth(date); + expect(result).toBe(expected); + }); + // 엣지 케이스 추가 + it("윤년의 2월 29일을 '2024년 2월'로 반환한다", () => { + const date = new Date('2024-02-29'); + const expected = '2024년 2월'; + const result = formatMonth(date); + expect(result).toBe(expected); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); - - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); - - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); - - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); - - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); - - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + const start = new Date('2025-07-01'); + const end = new Date('2025-07-31'); + + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + const date = new Date('2025-07-10'); + const result = isDateInRange(date, start, end); + expect(result).toBe(true); + }); + + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + const date = new Date('2025-07-01'); + const result = isDateInRange(date, start, end); + expect(result).toBe(true); + }); + + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + const date = new Date('2025-07-31'); + const result = isDateInRange(date, start, end); + expect(result).toBe(true); + }); + + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + const date = new Date('2025-06-30'); + const result = isDateInRange(date, start, end); + expect(result).toBe(false); + }); + + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + const date = new Date('2025-08-01'); + const result = isDateInRange(date, start, end); + expect(result).toBe(false); + }); + + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + const date = new Date('2025-07-15'); + const result = isDateInRange(date, end, start); + expect(result).toBe(false); + }); + + // 시작일의 마지막 순간으로 경계값 테스트 + it('시작일의 마지막 순간(2025-07-01 23:59:59)에 대해 true를 반환한다', () => { + const date = new Date('2025-07-01T23:59:59.999'); + expect(isDateInRange(date, start, end)).toBe(true); + }); + // 종료일의 첫 순간으로 경계값 테스트 + it('종료일의 첫 순간(2025-07-31 00:00:00)에 대해 true를 반환한다', () => { + const date = new Date('2025-07-31T00:00:00.000'); + expect(isDateInRange(date, start, end)).toBe(true); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + const result = fillZero(5, 2); + expect(result).toBe('05'); + }); + + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + const result = fillZero(10, 2); + expect(result).toBe('10'); + }); + + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + const result = fillZero(3, 3); + expect(result).toBe('003'); + }); + + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + const result = fillZero(100, 2); + expect(result).toBe('100'); + }); + + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + const result = fillZero(0, 2); + expect(result).toBe('00'); + }); + + it("0을 10자리로 변환하면 '0000000000'을 반환한다", () => { + const result = fillZero(0, 10); + expect(result).toBe('0000000000'); + }); + + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + const result = fillZero(1, 5); + expect(result).toBe('00001'); + }); + + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + const result = fillZero(3.14, 5); + expect(result).toBe('03.14'); + }); + + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + const result = fillZero(5); + expect(result).toBe('05'); + }); + + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + expect(fillZero(1, 0)).toBe('1'); + expect(fillZero(123, 2)).toBe('123'); + expect(fillZero(2147483647, 9)).toBe('2147483647'); + + expect(fillZero(123.45, 4)).toBe('123.45'); + expect(fillZero(12.34, 4)).toBe('12.34'); + }); +}); - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); +describe('formatDate', () => { + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + const date = new Date('2025-07-15'); + const expected = '2025-07-15'; - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); + const result = formatDate(date); - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); + expect(result).toBe(expected); + }); - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + const date = new Date('2025-06-20'); + const day = 30; + const expected = '2025-06-30'; - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); + const result = formatDate(date, day); - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); + expect(result).toBe(expected); + }); - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const date = new Date('2025-03-15'); // 3월 + const expected = '2025-03-15'; - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); -}); + const result = formatDate(date); -describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); + expect(result).toBe(expected); + }); - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const date = new Date('2025-07-05'); // 5일 + const expected = '2025-07-05'; - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + const result = formatDate(date); - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + expect(result).toBe(expected); + }); }); From 069c74b99475722e04080ab60f80150021b1a6c9 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 15:28:49 +0900 Subject: [PATCH 04/43] =?UTF-8?q?feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=9E=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=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__/unit/easy.eventOverlap.spec.ts | 370 ++++++++++++++++++- 1 file changed, 359 insertions(+), 11 deletions(-) diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..98469491 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -5,32 +5,380 @@ import { isOverlapping, 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'); + expect(result).toEqual(new Date('2025-07-01T14:30')); + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(6); + expect(result.getDate()).toBe(1); + expect(result.getHours()).toBe(14); + expect(result.getMinutes()).toBe(30); + }); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + const result = parseDateTime('invalid-date', '14:30'); + expect(isNaN(result.getTime())).toBe(true); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + const result = parseDateTime('2025-07-01', 'invalid-time'); + expect(isNaN(result.getTime())).toBe(true); + }); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + const result = parseDateTime('', '14:30'); + expect(isNaN(result.getTime())).toBe(true); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + const event: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const result = convertEventToDateRange(event); + expect(result.start).toEqual(new Date('2025-07-01T09:00')); + expect(result.end).toEqual(new Date('2025-07-01T10:00')); + }); - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const invalidEvent = { ...event, date: 'invalid-date' }; + const result = convertEventToDateRange(invalidEvent); + expect(isNaN(result.start.getTime())).toBe(true); + expect(isNaN(result.end.getTime())).toBe(true); + }); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const invalidEvent = { ...event, startTime: 'invalid-time', endTime: 'invalid-time' }; + const result = convertEventToDateRange(invalidEvent); + expect(isNaN(result.start.getTime())).toBe(true); + expect(isNaN(result.end.getTime())).toBe(true); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + const event1: Event = { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const event2: Event = { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(true); + }); + + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + const event1: Event = { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const event2: Event = { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(false); + }); + + it('경계선상에서 만나는 이벤트는 겹치지 않는다', () => { + const event1: Event = { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const event2: Event = { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(false); + }); + + it('하나의 이벤트가 다른 이벤트를 완전히 포함하는 경우 true를 반환한다', () => { + const event1: Event = { + id: '1', + title: '긴 이벤트', + date: '2025-07-01', + startTime: '08:00', + endTime: '12:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const event2: Event = { + id: '2', + title: '짧은 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(true); + }); + + it('날짜가 변경되는 자정에 두 이벤트가 경계선상에서 만나는 경우 false를 반환한다', () => { + const event1: Event = { + id: '1', + title: '자정을 지나는 이벤트', + date: '2025-07-01', + startTime: '23:58:59', + endTime: '23:59:59', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + const event2: Event = { + id: '2', + title: '다음 날 아침 이벤트', + date: '2025-07-02', + startTime: '00:00:00', + endTime: '00:00:01', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const newEvent: Event = { + id: 'new', + title: '새 이벤트', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const existingEvents: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-07-01', + startTime: '09:45', + endTime: '10:15', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]; + + const overlappingEvents = findOverlappingEvents(newEvent, existingEvents); + + expect(overlappingEvents).toHaveLength(3); + expect(overlappingEvents.map((e) => e.id)).toContain('1'); + expect(overlappingEvents.map((e) => e.id)).toContain('2'); + expect(overlappingEvents.map((e) => e.id)).toContain('3'); + }); + + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent: Event = { + id: 'new', + title: '새 이벤트', + date: '2025-07-01', + startTime: '11:00', + endTime: '12:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const existingEvents: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '08:00', + endTime: '09:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]; + + const overlappingEvents = findOverlappingEvents(newEvent, existingEvents); + + expect(overlappingEvents).toHaveLength(0); + }); + + it('자신과 같은 ID를 가진 이벤트는 제외한다', () => { + const newEvent: Event = { + id: '1', + title: '기존 이벤트와 같은 ID', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const existingEvents: Event[] = [ + { + id: '1', + title: '기존 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]; + + const overlappingEvents = findOverlappingEvents(newEvent, existingEvents); + + expect(overlappingEvents).toHaveLength(0); + }); + + // 엣지 케이스 추가 + it('기존 이벤트가 아무것도 등록되어있지 않다면 빈 배열을 반환한다', () => { + const newEvent: Event = { + id: '1', + title: '기존 이벤트와 같은 ID', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + const existingEvents: Event[] = []; + const overlappingEvents = findOverlappingEvents(newEvent, existingEvents); + expect(overlappingEvents).toHaveLength(0); + }); }); From 69753524effb7983b5394f87407b5277d33afdae Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 16:34:06 +0900 Subject: [PATCH 05/43] =?UTF-8?q?feat:=20eventUtils=EC=9D=98=20getFiltered?= =?UTF-8?q?Events=20=ED=95=A8=EC=88=98=20=ED=85=8C=EC=8A=A4=ED=8C=85,=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4,=20=EC=A3=BC=EA=B0=84,=20=EC=9B=94?= =?UTF-8?q?=EA=B0=84=EB=B3=84=EB=A1=9C=20=EA=B7=B8=EB=A3=B9=ED=99=94=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 | 189 ++++++++++++++++++++- 1 file changed, 181 insertions(+), 8 deletions(-) diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..82d70a58 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -2,19 +2,192 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + // 7월 이벤트 갯수 : 5 + // 6월 이벤트 갯수 : 1 + // 8월 이벤트 갯수 : 1 + // 총 이벤트 갯수 : 7 - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + const mockEvents: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 이벤트입니다.', + location: '회의실 A', + category: '회의.', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + description: '두 번째 이벤트입니다', + location: '회의실 B', + category: '교육', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-07-08', + startTime: '10:00', + endTime: '11:00', + description: '세 번째 이벤트입니다', + location: '강당', + category: '세미나', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + { + id: '4', + title: '이벤트 4', + date: '2025-07-15', + startTime: '16:00', + endTime: '17:00', + description: '네 번째 이벤트입니다', + location: '온라인', + category: '웨비나', + repeat: { type: 'none', interval: 1 }, + notificationTime: 45, + }, + { + id: '5', + title: '이벤트 5', + date: '2025-06-30', + startTime: '13:00', + endTime: '14:00', + description: '다섯 번째 이벤트입니다', + location: '회의실 C', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 20, + }, + { + id: '6', + title: '이벤트 6', + date: '2025-08-01', + startTime: '09:00', + endTime: '10:00', + description: '여섯 번째 이벤트입니다', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '7', + title: '이벤트 7', + date: '2025-07-31', + startTime: '09:00', + endTime: '10:00', + description: '일곱 번째 이벤트입니다', + location: '회의실 C', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]; - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + describe('검색어 필터링', () => { + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '이벤트 2', currentDate, 'week'); - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + expect(result[0].title).toBe('이벤트 2'); + }); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const currentDate = new Date('2025-07-01'); + const result1 = getFilteredEvents(mockEvents, '회의실 a', currentDate, 'week'); + const result2 = getFilteredEvents(mockEvents, '회의실 A', currentDate, 'week'); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + expect(result1).toHaveLength(1); + expect(result2).toHaveLength(1); + expect(result1).toEqual(result2); + }); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + it('검색어가 제목, 설명, 위치에서 모두 검색된다', () => { + const currentDate = new Date('2025-07-01'); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + // 제목으로 검색 + const titleResult = getFilteredEvents(mockEvents, '이벤트 1', currentDate, 'week'); + expect(titleResult).toHaveLength(1); + expect(titleResult[0].id).toBe('1'); + + // 설명으로 검색 + const descResult = getFilteredEvents(mockEvents, '첫 번째', currentDate, 'week'); + expect(descResult).toHaveLength(1); + expect(descResult[0].id).toBe('1'); + + // 위치로 검색 + const locationResult = getFilteredEvents(mockEvents, '회의실 A', currentDate, 'week'); + expect(locationResult).toHaveLength(1); + expect(locationResult[0].id).toBe('1'); + }); + }); + + describe('주간 뷰 필터링', () => { + it('검색어가 없을 때 주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + const currentDate = new Date('2025-07-01'); // 2025-07-01은 화요일 + const result = getFilteredEvents(mockEvents, '', currentDate, 'week'); + + expect(result).toHaveLength(3); + expect(result.map((e) => e.id).sort()).toEqual(['1', '2', '5']); + }); + + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '이벤트', currentDate, 'week'); + + expect(result).toHaveLength(3); + expect(result.map((e) => e.id).sort()).toEqual(['1', '2', '5']); + }); + }); + + describe('월간 뷰 필터링', () => { + it('검색어가 없을 때 월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); + + expect(result).toHaveLength(5); + expect(result.map((e) => e.id).sort()).toEqual(['1', '2', '3', '4', '7']); + }); + // 엣지 케이스 추가 + it('월간 뷰에서 2025년 7월에 속하지 않는 이벤트는 반환하지 않는다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); + expect(result.map((e) => e.id).sort()).not.toContain('5'); + expect(result.map((e) => e.id).sort()).not.toContain('6'); + }); + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); + + expect(result.map((e) => e.date)).not.toContain('2025-06-30'); + expect(result.map((e) => e.date)).not.toContain('2025-08-01'); + + expect(result).toHaveLength(5); + + expect(result.map((e) => e.id).sort()).toEqual(['1', '2', '3', '4', '7']); + }); + }); + + // 의미있는 테스트일까? + describe('빈 이벤트 리스트', () => { + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents([], '이벤트', currentDate, 'week'); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + }); }); From 1714f3c4f4dad1ada61cd2b2ce6f7b0f824ef658 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 16:55:12 +0900 Subject: [PATCH 06/43] =?UTF-8?q?feat:=20=EA=B3=B5=ED=9C=B4=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(=EB=8B=A8=EC=9D=BC=20=EA=B3=B5=ED=9C=B4=EC=9D=BC?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=B4=EC=84=9C=EB=8A=94=20=EC=95=84?= =?UTF-8?q?=EC=A7=81=20=ED=95=84=EC=9A=94=ED=95=9C=EC=A7=80=20=EA=B3=A0?= =?UTF-8?q?=EB=AF=BC=ED=95=B4=EC=95=BC=ED=95=A8.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/unit/easy.fetchHolidays.spec.ts | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..cca09363 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,53 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; + describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + const date = new Date('2025-01-01'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + }); + + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + const date = new Date('2025-02-15'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({}); + expect(Object.keys(holidays)).toHaveLength(0); + }); + + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + const date = new Date('2025-10-01'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + }); + + // 추가는 했는데 필요할지 의문이다. + it('단일 공휴일이 있는 월에 대해 정확한 공휴일을 반환한다', () => { + const date = new Date('2025-03-01'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({ + '2025-03-01': '삼일절', + }); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + const date2 = new Date('2025-12-01'); + const holidays2 = fetchHolidays(date2); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + expect(holidays2).toEqual({ + '2025-12-25': '크리스마스', + }); + }); }); From c2cda49b38d332dc768c29f75d871a8802bb4c81 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 17:25:58 +0900 Subject: [PATCH 07/43] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EC=97=90=20=EB=8C=80=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20(=EC=A4=91=EB=B3=B5=EC=95=8C=EB=A6=BC,=20=EA=B8=B4?= =?UTF-8?q?=20=ED=85=8D=EC=8A=A4=ED=8A=B8,=20=EC=9D=B4=EB=AA=A8=EC=A7=80,?= =?UTF-8?q?=20=ED=8A=B9=EC=88=98=EB=AC=B8=EC=9E=90=EB=A5=BC=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=ED=95=9C=20=EC=97=A3=EC=A7=80=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=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 | 214 +++++++++++++++++- 1 file changed, 209 insertions(+), 5 deletions(-) diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..0cf71a5e 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -2,15 +2,219 @@ import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + const mockEvents: Event[] = [ + { + id: '1', + title: '오전 회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '오전 회의입니다', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, // 15분 전 알림 + }, + { + id: '2', + title: '점심 약속', + date: '2025-07-01', + startTime: '12:00', + endTime: '13:00', + description: '점심 약속입니다', + location: '식당', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, // 30분 전 알림 + }, + { + id: '3', + title: '오후 세미나', + date: '2025-07-01', + startTime: '14:00', + endTime: '15:00', + description: '오후 세미나입니다', + location: '강당', + category: '교육', + repeat: { type: 'none', interval: 1 }, + notificationTime: 150, // 150분 전 알림 + }, + { + id: '4', + title: '저녁 회의', + date: '2025-07-01', + startTime: '18:00', + endTime: '19:00', + description: '저녁 회의입니다', + location: '회의실 B', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, // 15분 전 알림 + }, + ]; - it('이미 알림이 간 이벤트는 제외한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + const now = new Date('2025-07-01T08:45:00'); + const notifiedEvents: string[] = []; - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + expect(upcomingEvents).toHaveLength(1); + expect(upcomingEvents[0].id).toBe('1'); + expect(upcomingEvents[0].title).toBe('오전 회의'); + }); - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + it('이미 알림이 간 이벤트는 제외한다', () => { + const now = new Date('2025-07-01T08:45:00'); + const notifiedEvents = ['1']; + + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + + expect(upcomingEvents).toHaveLength(0); + }); + + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + const now = new Date('2025-07-01T08:30:00'); + const notifiedEvents: string[] = []; + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + expect(upcomingEvents).toHaveLength(0); + }); + + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const now = new Date('2025-07-01T09:15:00'); + const notifiedEvents: string[] = []; + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + expect(upcomingEvents).toHaveLength(0); + }); + + // 여러 이벤트 처리 케이스 추가 + it('여러 이벤트의 알림 시간이 동시에 도래하면 모두 반환한다', () => { + const now = new Date('2025-07-01T11:30:00'); + const notifiedEvents: string[] = []; + + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + + expect(upcomingEvents).toHaveLength(2); + expect(upcomingEvents.map((e) => e.id)).toContain('2'); + expect(upcomingEvents.map((e) => e.id)).toContain('3'); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const event: Event = { + id: '1', + title: '중요한 회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '중요한 회의입니다', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const message = createNotificationMessage(event); + + expect(message).toBe('15분 후 중요한 회의 일정이 시작됩니다.'); + }); + + // 여러 엣지 케이스들 추가 + it('다양한 알림 시간에 대해 올바른 메시지를 생성한다', () => { + const event1: Event = { + id: '1', + title: '회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '회의입니다', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }; + + const event2: Event = { + id: '2', + title: '세미나', + date: '2025-07-01', + startTime: '14:00', + endTime: '15:00', + description: '세미나입니다', + location: '강당', + category: '교육', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }; + + const message1 = createNotificationMessage(event1); + const message2 = createNotificationMessage(event2); + + expect(message1).toBe('1분 후 회의 일정이 시작됩니다.'); + expect(message2).toBe('60분 후 세미나 일정이 시작됩니다.'); + }); + + it('특수 문자가 포함된 이벤트에 대해서 특수 문자가 포함된 메시지를 생성한다', () => { + const event: Event = { + id: '1', + title: '회의 & 세미나 (중요)', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '특수 문자가 포함된 제목', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }; + + const message = createNotificationMessage(event); + + expect(message).toBe('30분 후 회의 & 세미나 (중요) 일정이 시작됩니다.'); + }); + + // 긴 텍스트에 대한 경계값 테스트 + it('긴 제목을 가진 이벤트에 대해서 긴 제목을 포함한 메시지를 생성한다', () => { + const event: Event = { + id: '1', + title: + '매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트 매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '긴 제목 테스트', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 45, + }; + + const message = createNotificationMessage(event); + + expect(message).toBe( + '45분 후 매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트 매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트 일정이 시작됩니다.' + ); + }); + + // 이모지 인코딩 테스트 + it('이모지를 포함한 이벤트에 대해서 이모지를 포함한 메시지를 생성한다', () => { + const event: Event = { + id: '1', + title: '🔥🚨👉 급한 중요한 회의🏢 💥💣💥 이벤트 1👐👈👊', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '긴 제목 테스트', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 45, + }; + + const message = createNotificationMessage(event); + + expect(message).toBe( + '45분 후 🔥🚨👉 급한 중요한 회의🏢 💥💣💥 이벤트 1👐👈👊 일정이 시작됩니다.' + ); + }); }); From 5e5d795743614500482d22325c7e2fdd5ad2e7b0 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 17:29:13 +0900 Subject: [PATCH 08/43] =?UTF-8?q?feat:=20timeValidation=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/easy.timeValidation.spec.ts | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..2b6798ec 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,45 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('14:00', '13:00'); - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + expect(result.startTimeError).toBe('시작 시간은 종료 시간보다 빨라야 합니다.'); + expect(result.endTimeError).toBe('종료 시간은 시작 시간보다 늦어야 합니다.'); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('14:00', '14:00'); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + expect(result.startTimeError).toBe('시작 시간은 종료 시간보다 빨라야 합니다.'); + expect(result.endTimeError).toBe('종료 시간은 시작 시간보다 늦어야 합니다.'); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + const result = getTimeErrorMessage('13:00', '14:00'); - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('시작 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', '14:00'); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('종료 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('13:00', ''); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', ''); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); }); From 408ecbc09742791508a09609b90bf55ddad6132c Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 17:39:59 +0900 Subject: [PATCH 09/43] =?UTF-8?q?feat:=20useSearch=20=EC=9C=A0=EB=8B=9B=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80(=20=EC=A3=BC?= =?UTF-8?q?=EA=B0=84=20=EB=B7=B0=EC=97=90=EC=84=9C=20=EC=9B=94=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=EB=A5=BC=20=EB=84=98=EC=96=B4=EA=B0=80=EB=8A=94=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=EA=B0=92=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=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 | 143 ++++++++++++++++++++- 1 file changed, 137 insertions(+), 6 deletions(-) diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 7691e944..199d69af 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -1,15 +1,146 @@ -// test push 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-04-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 업무 계획 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '점심 약속', + date: '2025-04-15', + startTime: '12:00', + endTime: '13:00', + description: '팀원들과 점심 식사', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 0, + }, + { + id: '3', + title: '고객 미팅', + date: '2025-04-16', + startTime: '14:00', + endTime: '15:00', + description: '신규 프로젝트 제안', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + { + id: '4', + title: '프로젝트 회의', + date: '2025-06-29', + startTime: '14:00', + endTime: '15:00', + description: '프로젝트 회의', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + { + id: '5', + title: '오후 회의', + date: '2025-07-05', + startTime: '14:00', + endTime: '15:00', + description: '오후 회의', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, +]; -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); +const currentDate = new Date('2025-04-15'); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); +it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); + expect(result.current.filteredEvents).toHaveLength(3); // 4월 15일 주간 뷰에서는 3개 이벤트 (13일~19일 범위) + expect(result.current.searchTerm).toBe(''); +}); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); +it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); +}); + +it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('점심 약속'); + + act(() => { + result.current.setSearchTerm('업무 계획'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); + + act(() => { + result.current.setSearchTerm('회의실'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); +}); + +it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const { result: weekResult } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + expect(weekResult.current.filteredEvents).toHaveLength(3); // 4월 15일 주간에는 3개 (13일~19일 범위) + + const { result: monthResult } = renderHook(() => useSearch(mockEvents, currentDate, 'month')); + expect(monthResult.current.filteredEvents).toHaveLength(3); // 4월 전체에는 3개 +}); + +it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); + + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('점심 약속'); +}); + +// 주간 뷰에서 월 경계를 넘어가는 경계값 테스트 추가 +it('주간 뷰에서 월 경계를 넘어가는 이벤트를 올바르게 포함해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-07-01'), 'week')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.filteredEvents).toHaveLength(2); + expect(result.current.filteredEvents.map((e) => e.id)).toContain('4'); + expect(result.current.filteredEvents.map((e) => e.id)).toContain('5'); +}); From 5014edcef5789088163ff43fc5e9bc5d246f0904 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 18:15:58 +0900 Subject: [PATCH 10/43] =?UTF-8?q?fix:=20describe=EB=A1=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B7=B8=EB=A3=B9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/hooks/easy.useSearch.spec.ts | 247 +++++++++++---------- 1 file changed, 124 insertions(+), 123 deletions(-) diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 199d69af..c001e128 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -3,144 +3,145 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; import { Event } from '../../types.ts'; -const mockEvents: Event[] = [ - { - id: '1', - title: '팀 회의', - date: '2025-04-15', - startTime: '09:00', - endTime: '10:00', - description: '주간 업무 계획 회의', - location: '회의실 A', - category: '업무', - repeat: { type: 'none', interval: 1 }, - notificationTime: 15, - }, - { - id: '2', - title: '점심 약속', - date: '2025-04-15', - startTime: '12:00', - endTime: '13:00', - description: '팀원들과 점심 식사', - location: '회사 근처 식당', - category: '개인', - repeat: { type: 'none', interval: 1 }, - notificationTime: 0, - }, - { - id: '3', - title: '고객 미팅', - date: '2025-04-16', - startTime: '14:00', - endTime: '15:00', - description: '신규 프로젝트 제안', - location: '온라인', - category: '업무', - repeat: { type: 'none', interval: 1 }, - notificationTime: 30, - }, - { - id: '4', - title: '프로젝트 회의', - date: '2025-06-29', - startTime: '14:00', - endTime: '15:00', - description: '프로젝트 회의', - location: '온라인', - category: '업무', - repeat: { type: 'none', interval: 1 }, - notificationTime: 30, - }, - { - id: '5', - title: '오후 회의', - date: '2025-07-05', - startTime: '14:00', - endTime: '15:00', - description: '오후 회의', - location: '온라인', - category: '업무', - repeat: { type: 'none', interval: 1 }, - notificationTime: 30, - }, -]; - -const currentDate = new Date('2025-04-15'); - -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { - const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); - - expect(result.current.filteredEvents).toHaveLength(3); // 4월 15일 주간 뷰에서는 3개 이벤트 (13일~19일 범위) - expect(result.current.searchTerm).toBe(''); -}); - -it('검색어에 맞는 이벤트만 필터링해야 한다', () => { - const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); - - act(() => { - result.current.setSearchTerm('회의'); +describe('useSearch Hook Test', () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '팀 회의', + date: '2025-04-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 업무 계획 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '점심 약속', + date: '2025-04-15', + startTime: '12:00', + endTime: '13:00', + description: '팀원들과 점심 식사', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 0, + }, + { + id: '3', + title: '고객 미팅', + date: '2025-04-16', + startTime: '14:00', + endTime: '15:00', + description: '신규 프로젝트 제안', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + { + id: '4', + title: '프로젝트 회의', + date: '2025-06-29', + startTime: '14:00', + endTime: '15:00', + description: '프로젝트 회의', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + { + id: '5', + title: '오후 회의', + date: '2025-07-05', + startTime: '14:00', + endTime: '15:00', + description: '오후 회의', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + ]; + + const currentDate = new Date('2025-04-15'); + it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + + expect(result.current.filteredEvents).toHaveLength(3); // 4월 15일 주간 뷰에서는 3개 이벤트 (13일~19일 범위) + expect(result.current.searchTerm).toBe(''); }); - expect(result.current.filteredEvents).toHaveLength(1); - expect(result.current.filteredEvents[0].title).toBe('팀 회의'); -}); + it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { - const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + act(() => { + result.current.setSearchTerm('회의'); + }); - act(() => { - result.current.setSearchTerm('점심'); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); }); - expect(result.current.filteredEvents).toHaveLength(1); - expect(result.current.filteredEvents[0].title).toBe('점심 약속'); - act(() => { - result.current.setSearchTerm('업무 계획'); + it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('점심 약속'); + + act(() => { + result.current.setSearchTerm('업무 계획'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); + + act(() => { + result.current.setSearchTerm('회의실'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); }); - expect(result.current.filteredEvents).toHaveLength(1); - expect(result.current.filteredEvents[0].title).toBe('팀 회의'); - act(() => { - result.current.setSearchTerm('회의실'); - }); - expect(result.current.filteredEvents).toHaveLength(1); - expect(result.current.filteredEvents[0].title).toBe('팀 회의'); -}); + it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const { result: weekResult } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + expect(weekResult.current.filteredEvents).toHaveLength(3); // 4월 15일 주간에는 3개 (13일~19일 범위) -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { - const { result: weekResult } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); - expect(weekResult.current.filteredEvents).toHaveLength(3); // 4월 15일 주간에는 3개 (13일~19일 범위) + const { result: monthResult } = renderHook(() => useSearch(mockEvents, currentDate, 'month')); + expect(monthResult.current.filteredEvents).toHaveLength(3); // 4월 전체에는 3개 + }); - const { result: monthResult } = renderHook(() => useSearch(mockEvents, currentDate, 'month')); - expect(monthResult.current.filteredEvents).toHaveLength(3); // 4월 전체에는 3개 -}); + it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { - const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + act(() => { + result.current.setSearchTerm('회의'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); - act(() => { - result.current.setSearchTerm('회의'); + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('점심 약속'); }); - expect(result.current.filteredEvents).toHaveLength(1); - expect(result.current.filteredEvents[0].title).toBe('팀 회의'); - act(() => { - result.current.setSearchTerm('점심'); - }); - expect(result.current.filteredEvents).toHaveLength(1); - expect(result.current.filteredEvents[0].title).toBe('점심 약속'); -}); + // 주간 뷰에서 월 경계를 넘어가는 경계값 테스트 추가 + it('주간 뷰에서 월 경계를 넘어가는 이벤트를 올바르게 포함해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-07-01'), 'week')); -// 주간 뷰에서 월 경계를 넘어가는 경계값 테스트 추가 -it('주간 뷰에서 월 경계를 넘어가는 이벤트를 올바르게 포함해야 한다', () => { - const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-07-01'), 'week')); + act(() => { + result.current.setSearchTerm('회의'); + }); - act(() => { - result.current.setSearchTerm('회의'); + expect(result.current.filteredEvents).toHaveLength(2); + expect(result.current.filteredEvents.map((e) => e.id)).toContain('4'); + expect(result.current.filteredEvents.map((e) => e.id)).toContain('5'); }); - - expect(result.current.filteredEvents).toHaveLength(2); - expect(result.current.filteredEvents.map((e) => e.id)).toContain('4'); - expect(result.current.filteredEvents.map((e) => e.id)).toContain('5'); }); From 0ef400eac36a3046e2dbc92150ac2a2daa873755 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 18:17:56 +0900 Subject: [PATCH 11/43] =?UTF-8?q?feat:=20easy=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=A0=84=EC=B2=B4=20=EB=8F=8C=EB=A6=B4=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8A=94=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?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 --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index b01b2b4b..e7d2cbb1 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", + "test:easy": "vitest --run easy", + "test:easy:watch": "vitest easy", "build": "tsc -b && vite build", "lint:eslint": "eslint . --ext ts,tsx --report-unused-disable-directives", "lint:tsc": "tsc --pretty", From 25cd108e621c0a6adc76c755a4cf511a2159b009 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 18:18:28 +0900 Subject: [PATCH 12/43] =?UTF-8?q?feat:=20useCalendarView=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A3=BC=EA=B0=84,=20=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=EA=B0=92=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/easy.useCalendarView.spec.ts | 170 ++++++++++++++++-- 1 file changed, 159 insertions(+), 11 deletions(-) diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..f10e22fd 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -3,22 +3,170 @@ import { act, renderHook } from '@testing-library/react'; import { useCalendarView } from '../../hooks/useCalendarView.ts'; import { assertDate } from '../utils.ts'; -describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); +describe('useCalendarView Hook Test', () => { + describe('초기 상태', () => { + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + expect(result.current.view).toBe('month'); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); -}); + it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + assertDate(result.current.currentDate, new Date('2025-10-01')); + }); + + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + expect(result.current.holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + }); + }); + + describe('뷰 변경', () => { + it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + expect(result.current.view).toBe('week'); + }); + + it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-10-08')); + }); + + it("주간 뷰에서 이전으로 navigate시 7일 전 '2025-09-24' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2025-09-24')); + }); + + it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-11-01')); + }); + + it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); + act(() => { + result.current.navigate('prev'); + }); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + assertDate(result.current.currentDate, new Date('2025-09-01')); + }); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => { + const { result } = renderHook(() => useCalendarView()); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + act(() => { + result.current.setCurrentDate(new Date('2025-03-01')); + }); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); + expect(result.current.holidays).toEqual({ + '2025-03-01': '삼일절', + }); + }); + }); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); + describe('연도 경계 테스트', () => { + it('2025년 1월에서 이전으로 이동 시 2024년 12월로 바뀌어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-01-05')); + }); + + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2024-12-01')); + }); + + it('2024년 12월에서 다음으로 이동 시 2025년 1월로 바뀌어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2024-12-01')); + }); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-01-01')); + }); + }); + + describe('주간 뷰 경계 테스트', () => { + it('2024년 12월 마지막 주에서 다음으로 이동 시 2025년 1월 첫째 주로 바뀌어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.setCurrentDate(new Date('2024-12-29')); + }); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-01-05')); + }); + + it('2025년 1월 첫째 주에서 이전으로 이동 시 2024년 12월 마지막 주로 바뀌어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.setCurrentDate(new Date('2025-01-05')); + }); + + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2024-12-29')); + }); + }); +}); From f715382be7451c8982ccc8b406789b202ebd34f7 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Thu, 21 Aug 2025 20:39:12 +0900 Subject: [PATCH 13/43] chore: test push --- src/hooks/useEventOperations.ts | 1 + src/setupTests.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/useEventOperations.ts b/src/hooks/useEventOperations.ts index 3216cc05..884d600b 100644 --- a/src/hooks/useEventOperations.ts +++ b/src/hooks/useEventOperations.ts @@ -1,3 +1,4 @@ +// test push import { useSnackbar } from 'notistack'; import { useEffect, useState } from 'react'; diff --git a/src/setupTests.ts b/src/setupTests.ts index bc268d06..566a9d79 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -4,14 +4,18 @@ import '@testing-library/jest-dom'; import { handlers } from './__mocks__/handlers'; /* msw */ -export const server = setupServer(...handlers); +export const server = setupServer(); beforeAll(() => { server.listen(); + vi.useFakeTimers({ shouldAdvanceTime: true }); }); beforeEach(() => { expect.hasAssertions(); + // 기본 핸들러를 먼저 설정 + server.use(...handlers); + vi.setSystemTime('2025-10-01'); }); afterEach(() => { From 7e8aafc0c44fe7f4fd676a25f718592a037ff704 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 01:23:46 +0900 Subject: [PATCH 14/43] =?UTF-8?q?feat:=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EB=B3=91=EB=A0=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__mocks__/handlers.ts | 37 +++++++++++++++++--- src/__mocks__/handlersUtils.ts | 62 ++++++++++++++++++++++++++++++++++ src/setupTests.ts | 6 +--- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/__mocks__/handlers.ts b/src/__mocks__/handlers.ts index 88cb1f8c..30da72bb 100644 --- a/src/__mocks__/handlers.ts +++ b/src/__mocks__/handlers.ts @@ -3,15 +3,42 @@ import { http, HttpResponse } from 'msw'; import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; import { Event } from '../types'; -// ! TODO: // ! HARD // ! 각 응답에 대한 MSW 핸들러를 작성해주세요. GET 요청은 이미 작성되어 있는 events json을 활용해주세요. + +let mockEvents: Event[] = structuredClone(events) as Event[]; + export const handlers = [ - http.get('/api/events', () => {}), + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.post('/api/events', async ({ request }) => { + const newEvent = (await request.json()) as Event; + mockEvents.push(newEvent); + return HttpResponse.json({ event: newEvent }, { status: 201 }); + }), + http.put('/api/events/:id', async ({ request, params }) => { + const updatedEvent = (await request.json()) as Event; + const index = mockEvents.findIndex((event) => event.id === params.id); - http.post('/api/events', async ({ request }) => {}), + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } - http.put('/api/events/:id', async ({ params, request }) => {}), + mockEvents[index] = { ...updatedEvent, id: params.id as string }; + return HttpResponse.json({ event: updatedEvent }, { status: 200 }); + }), + http.delete('/api/events/:id', async ({ params }) => { + const index = mockEvents.findIndex((event) => event.id === params.id); + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } - http.delete('/api/events/:id', ({ params }) => {}), + mockEvents.splice(index, 1); + return HttpResponse.json({ status: 200 }); + }), ]; + +export const initMockEvents = () => { + mockEvents = structuredClone(events) as Event[]; +}; diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index 405837ec..01ff9176 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -1,10 +1,72 @@ +import { http, HttpResponse } from 'msw'; import { Event } from '../types'; +import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; // ! Hard // ! 이벤트는 생성, 수정 되면 fetch를 다시 해 상태를 업데이트 합니다. 이를 위한 제어가 필요할 것 같은데요. 어떻게 작성해야 테스트가 병렬로 돌아도 안정적이게 동작할까요? // ! 아래 이름을 사용하지 않아도 되니, 독립적이게 테스트를 구동할 수 있는 방법을 찾아보세요. 그리고 이 로직을 PR에 설명해주세요. + +// 각 테스트마다 독립적인 상태를 가지도록 클로저를 사용 export const setupMockHandlerCreation = (initEvents = [] as Event[]) => {}; export const setupMockHandlerUpdating = () => {}; export const setupMockHandlerDeletion = () => {}; + +// 기존 함수는 호환성을 위해 유지하되 개선 +export const createMockHandlers = (initialEvents: Event[] = []) => { + let mockEvents: Event[] = structuredClone(initialEvents); + + return [ + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + + http.post('/api/events', async ({ request }) => { + const newEvent = (await request.json()) as Omit; + const eventWithId: Event = { + ...newEvent, + id: Date.now().toString(), + }; + mockEvents.push(eventWithId); + return HttpResponse.json( + { + success: true, + event: eventWithId, + message: '일정이 성공적으로 추가되었습니다.', + }, + { status: 201 } + ); + }), + + http.put('/api/events/:id', async ({ request, params }) => { + const updatedEvent = (await request.json()) as Partial; + const index = mockEvents.findIndex((event) => event.id === params.id); + + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } + + mockEvents[index] = { ...mockEvents[index], ...updatedEvent, id: params.id as string }; + return HttpResponse.json( + { + success: true, + event: mockEvents[index], + message: '일정이 성공적으로 수정되었습니다.', + }, + { status: 200 } + ); + }), + + http.delete('/api/events/:id', ({ params }) => { + const index = mockEvents.findIndex((event) => event.id === params.id); + + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } + + const [deletedEvent] = mockEvents.splice(index, 1); + return HttpResponse.json({ event: deletedEvent }, { status: 200 }); + }), + ]; +}; diff --git a/src/setupTests.ts b/src/setupTests.ts index 566a9d79..e39f8fac 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,10 +1,8 @@ import { setupServer } from 'msw/node'; import '@testing-library/jest-dom'; - import { handlers } from './__mocks__/handlers'; -/* msw */ -export const server = setupServer(); +export const server = setupServer(...handlers); beforeAll(() => { server.listen(); @@ -13,8 +11,6 @@ beforeAll(() => { beforeEach(() => { expect.hasAssertions(); - // 기본 핸들러를 먼저 설정 - server.use(...handlers); vi.setSystemTime('2025-10-01'); }); From ea24b9a5b4e72d4fb0b2d8f1436ef1bf008b28e9 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 01:39:45 +0900 Subject: [PATCH 15/43] =?UTF-8?q?feat:=20intergration=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=EB=B7=B0=20=EA=B5=AC=ED=98=84=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 437 +++++++++++++++++++++- 1 file changed, 417 insertions(+), 20 deletions(-) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 15901d4e..cd835c7b 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,49 +1,446 @@ +import App from '../App'; +import { server } from '../setupTests'; +import { Event } from '../types'; +import { createMockHandlers } from '../__mocks__/handlersUtils'; +import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act } from '@testing-library/react'; +import { render, screen, within, act, waitFor } from '@testing-library/react'; import { UserEvent, userEvent } from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; import { ReactElement } from 'react'; +import { vi } from 'vitest'; -import App from '../App'; -import { server } from '../setupTests'; -import { Event } from '../types'; +// 테스트용 테마 설정 +const theme = createTheme(); + +// 테스트용 App 컴포넌트 래퍼 +const AppWrapper = () => ( + + + + + + +); describe('일정 CRUD 및 기본 기능', () => { - it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { - // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + it.only('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { + server.use(...createMockHandlers([])); + + render(); + + // 새 일정 추가 폼 작성 + const user = userEvent.setup() as UserEvent; + + // 일정 추가 버튼 클릭 + const addButton = screen.getByRole('button', { name: /일정 추가/i }); + await user.click(addButton); + + await user.type(screen.getByLabelText('제목'), '테스트 회의'); + await user.type(screen.getByLabelText('날짜'), '2025-10-01'); + await user.type(screen.getByLabelText('시작 시간'), '14:00'); + await user.type(screen.getByLabelText('종료 시간'), '15:00'); + await user.type(screen.getByLabelText('설명'), '테스트용 회의입니다'); + await user.type(screen.getByLabelText('위치'), '온라인'); + + const categorySelect = screen.getByLabelText('카테고리') || document.querySelector('#category'); + // 카테고리 선택 + await user.click(categorySelect); + const categoryOption = screen.getByText('업무'); + await user.click(categoryOption); + + // 저장 버튼 클릭 + const saveButton = screen.getByTestId('event-submit-button'); + await user.click(saveButton); + + // 일정이 목록에 추가되었는지 확인 ( + await waitFor(() => { + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).getByText('테스트 회의')).toBeInTheDocument(); + }); + + // 모든 필드가 정확히 저장되었는지 확인 + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).getByText('2025-10-01')).toBeInTheDocument(); + expect(within(eventList).getByText('14:00 - 15:00')).toBeInTheDocument(); + expect(within(eventList).getByText('테스트용 회의입니다')).toBeInTheDocument(); + expect(within(eventList).getByText('온라인')).toBeInTheDocument(); + expect(within(eventList).getByText('카테고리: 업무')).toBeInTheDocument(); + }); + + it.only('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + server.use(...createMockHandlers(events as Event[])); + + render(); + + const user = userEvent.setup(); + + // 기존 일정이 표시되는지 확인 (일정 목록 내에서만 검색) + await waitFor(() => { + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).getByText('기존 회의')).toBeInTheDocument(); + }); + + // 일정 수정 버튼 클릭 (첫 번째 일정의 Edit 버튼) + const editButtons = screen.getAllByRole('button', { name: /Edit event/i }); + await user.click(editButtons[0]); // 첫 번째 Edit 버튼 클릭 + + // 제목 수정 + const titleInput = screen.getByDisplayValue('기존 회의'); + await user.clear(titleInput); + await user.type(titleInput, '수정된 회의'); + + // 설명 수정 + const descriptionInput = screen.getByDisplayValue('기존 팀 미팅'); + await user.clear(descriptionInput); + await user.type(descriptionInput, '수정된 팀 미팅'); + + // 저장 버튼 클릭 (data-testid 사용) + const saveButton = screen.getByTestId('event-submit-button'); + await user.click(saveButton); + + // 수정된 내용이 반영되었는지 확인 (일정 목록 내에서만 검색) + await waitFor(() => { + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).getByText('수정된 회의')).toBeInTheDocument(); + expect(within(eventList).getByText('수정된 팀 미팅')).toBeInTheDocument(); + }); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it.only('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + // MSW Mock Server 설정 (삭제용 - 내장된 mock 데이터 사용) + const customEvents: Event[] = [ + { + id: '1', + title: '삭제할 이벤트', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '삭제 테스트용 이벤트', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(customEvents)); + + render(); + + const user = userEvent.setup(); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + // 기존 일정이 표시되는지 확인 + const eventList = screen.getByTestId('event-list'); + await waitFor(() => { + expect(within(eventList).getByText('삭제할 이벤트')).toBeInTheDocument(); + }); + + // 삭제 버튼 클릭 (aria-label 사용) + const deleteButton = within(eventList).getByRole('button', { name: /Delete event/i }); + await user.click(deleteButton); + + // 일정이 목록에서 제거되었는지 확인 + await waitFor(() => { + expect(within(eventList).queryByText('삭제할 이벤트')).not.toBeInTheDocument(); + }); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it.only('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + vi.setSystemTime('2025-07-01'); + + const testEvents: Event[] = [ + { + id: '1', + title: '7월 둘째주 회의', + date: '2025-07-08', + startTime: '09:00', + endTime: '10:00', + description: '7월 둘째주 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '7월 둘째주 미팅', + date: '2025-07-10', + startTime: '14:00', + endTime: '15:00', + description: '7월 둘째주 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(testEvents)); + + render(); + const user = userEvent.setup(); + + // 주별 뷰로 변경 + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByTestId('week-option')); + + await waitFor(() => { + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).queryByText('7월 둘째주 회의')).not.toBeInTheDocument(); + expect(within(eventList).queryByText('7월 둘째주 미팅')).not.toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it.only('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + vi.setSystemTime('2025-07-01'); + + const testEvents: Event[] = [ + { + id: '1', + title: '7월 첫째주 회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '미팅미팅미팅~', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(testEvents)); + + render(); + + const user = userEvent.setup(); + + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByTestId('week-option')); + + await waitFor(() => { + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).queryByText('7월 첫째주 회의')).toBeInTheDocument(); + expect(within(eventList).queryByText('미팅미팅미팅~')).toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it.only('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + // MSW Mock Server 설정 (빈 일정) + server.use(...createMockHandlers([])); + + render(); + + const user = userEvent.setup(); + + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + await user.click(viewSelector); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + // Select가 열린 후 data-value로 Month 옵션 찾기 + const monthOption = screen.getByTestId('month-option'); + await user.click(monthOption); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + // 일정이 표시되지 않는지 확인 (일정 목록 컨테이너가 비어있거나 일정 데이터가 없는지 확인) + await waitFor(() => { + const eventList = screen.getByTestId('event-list'); + // 일정 목록 내에 실제 일정 데이터가 없는지 확인 + expect(within(eventList).queryByText('기존 회의')).not.toBeInTheDocument(); + expect(within(eventList).queryByText('기존 회의2')).not.toBeInTheDocument(); + expect(within(eventList).queryByText('삭제할 이벤트')).not.toBeInTheDocument(); + }); + }); + + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + // MSW Mock Server 설정 (일정 있음) + server.use(...createMockHandlers(events as Event[])); + + render(); + + const user = userEvent.setup(); + + // 월별 뷰 선택 (aria-label을 사용하여 안정적인 셀렉터 사용) + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + await user.click(viewSelector); + + // Select가 열린 후 data-value로 Month 옵션 찾기 + const monthOption = screen.getByTestId('month-option'); + await user.click(monthOption); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + // 일정이 표시되는지 확인 + await waitFor(() => { + expect(screen.getByText('기존 회의')).toBeInTheDocument(); + expect(screen.getByText('기존 회의2')).toBeInTheDocument(); + }); + }); + + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + // MSW Mock Server 설정 + server.use(...createMockHandlers()); + + render(); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + // 1월 1일이 공휴일로 표시되는지 확인 + // (공휴일 표시 로직이 있다면 해당 요소를 찾아서 확인) + await waitFor(() => { + const newYearHoliday = screen.queryByText('신정') || screen.queryByText('1월 1일'); + // 공휴일 표시 여부는 실제 구현에 따라 다를 수 있음 + // expect(newYearHoliday).toBeInTheDocument(); + }); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + // MSW Mock Server 설정 (빈 일정) + server.use(...createMockHandlers()); + + render(); + + const user = userEvent.setup(); + + // 검색어 입력 + const searchInput = screen.getByPlaceholderText(/검색어를 입력하세요/i); + await user.type(searchInput, '존재하지 않는 일정'); + + // 검색 결과가 없다는 메시지가 표시되는지 확인 + // (실제 UI에 해당 메시지가 있다면) + await waitFor(() => { + // expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + }); + + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + // MSW Mock Server 설정 (특정 일정 포함) + server.use(...createMockHandlers()); + + render(); + + const user = userEvent.setup(); + + // 검색어 입력 + const searchInput = screen.getByPlaceholderText(/검색어를 입력하세요/i); + await user.type(searchInput, '팀 회의'); + + // 검색 결과가 표시되는지 확인 + await waitFor(() => { + expect(screen.getByText('기존 회의')).toBeInTheDocument(); + expect(screen.getByText('기존 회의2')).toBeInTheDocument(); + }); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + // MSW Mock Server 설정 (일정 있음) + server.use(...createMockHandlers()); + + render(); + + const user = userEvent.setup(); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + // 검색어 입력 + const searchInput = screen.getByPlaceholderText(/검색어를 입력하세요/i); + await user.type(searchInput, '팀 회의'); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + // 검색어 지우기 + await user.clear(searchInput); + + // 모든 일정이 다시 표시되는지 확인 + await waitFor(() => { + expect(screen.getByText('기존 회의')).toBeInTheDocument(); + expect(screen.getByText('기존 회의2')).toBeInTheDocument(); + }); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + // MSW Mock Server 설정 (기존 일정 있음) + server.use(...createMockHandlers()); + + render(); + + const user = userEvent.setup(); + + // 일정 추가 버튼 클릭 + const addButton = screen.getByRole('button', { name: /일정 추가/i }); + await user.click(addButton); + + // 겹치는 시간으로 일정 작성 + const titleInput = screen.getByLabelText(/제목/i); + const dateInput = screen.getByLabelText(/날짜/i); + const startTimeInput = screen.getByLabelText(/시작 시간/i); + const endTimeInput = screen.getByLabelText(/종료 시간/i); + + await user.type(titleInput, '충돌하는 일정'); + await user.type(dateInput, '2025-08-15'); // 기존 일정과 같은 날짜 + await user.type(startTimeInput, '09:30'); // 기존 일정과 겹치는 시간 + await user.type(endTimeInput, '10:30'); + + // 저장 버튼 클릭 + const saveButton = screen.getByRole('button', { name: /일정 추가/i }); + await user.click(saveButton); + + // 시간 충돌 경고가 표시되는지 확인 + await waitFor(() => { + expect(screen.getByText(/시간 충돌|겹치는 시간|충돌/i)).toBeInTheDocument(); + }); + }); + + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + // MSW Mock Server 설정 (여러 일정) + server.use(...createMockHandlers()); + + render(); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + const user = userEvent.setup(); + + // 기존 일정 수정 (실제 App UI에 맞춰서) + const editButton = screen.getByRole('button', { name: /Edit event/i }); + await user.click(editButton); + + // 겹치는 시간으로 수정 + const startTimeInput = screen.getByDisplayValue('09:00'); + await user.clear(startTimeInput); + await user.type(startTimeInput, '11:30'); // 다른 일정과 겹치는 시간 + + // 저장 버튼 클릭 + const saveButton = screen.getByRole('button', { name: /일정 추가/i }); + await user.click(saveButton); + + // 시간 충돌 경고가 표시되는지 확인 + await waitFor(() => { + expect(screen.getByText(/시간 충돌|겹치는 시간|충돌/i)).toBeInTheDocument(); + }); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + // MSW Mock Server 설정 + server.use(...createMockHandlers()); + + render(); + + const user = userEvent.setup(); + + // 일정 추가 버튼 클릭 + const addButton = screen.getByRole('button', { name: /일정 추가/i }); + await user.click(addButton); + + // 알림 시간을 10분으로 설정 (실제 App UI에 맞춰서) + const notificationSelect = + screen.getByLabelText(/notification/i) || document.querySelector('#notification'); + await user.click(notificationSelect); + const notificationOption = screen.getByText('10분 전'); + await user.click(notificationOption); + + // 알림 텍스트가 표시되는지 확인 + await waitFor(() => { + expect(screen.getByText('10분 전')).toBeInTheDocument(); + }); +}); From b091ef4dcbb15681c38b48d2ef39a227affc8310 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:44:41 +0900 Subject: [PATCH 16/43] =?UTF-8?q?feat:=20data-testid=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 5 +++-- src/__tests__/hooks/easy.useSearch.spec.ts | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..47318393 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -497,13 +497,14 @@ function App() { diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index c001e128..988741b6 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -140,8 +140,7 @@ describe('useSearch Hook Test', () => { result.current.setSearchTerm('회의'); }); - expect(result.current.filteredEvents).toHaveLength(2); - expect(result.current.filteredEvents.map((e) => e.id)).toContain('4'); - expect(result.current.filteredEvents.map((e) => e.id)).toContain('5'); + expect(result.current.filteredEvents.map((e) => e.date)).toContain('2025-06-29'); + expect(result.current.filteredEvents.map((e) => e.date)).toContain('2025-07-05'); }); }); From bb0751b3399c185281ab77761aaeab95a423cd4d Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:45:02 +0900 Subject: [PATCH 17/43] =?UTF-8?q?feat:=20=EB=AA=A9=ED=82=B9=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__mocks__/handlersUtils.ts | 99 +++++++++++++++++----------------- 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index 01ff9176..0f58452a 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -1,6 +1,5 @@ import { http, HttpResponse } from 'msw'; import { Event } from '../types'; -import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; // ! Hard // ! 이벤트는 생성, 수정 되면 fetch를 다시 해 상태를 업데이트 합니다. 이를 위한 제어가 필요할 것 같은데요. 어떻게 작성해야 테스트가 병렬로 돌아도 안정적이게 동작할까요? @@ -9,64 +8,66 @@ import { events } from '../__mocks__/response/events.json' assert { type: 'json' // 각 테스트마다 독립적인 상태를 가지도록 클로저를 사용 export const setupMockHandlerCreation = (initEvents = [] as Event[]) => {}; -export const setupMockHandlerUpdating = () => {}; +export const setupMockHandlerUpdating = (initEvents = [] as Event[]) => {}; -export const setupMockHandlerDeletion = () => {}; +export const setupMockHandlerDeletion = (initEvents = [] as Event[]) => {}; // 기존 함수는 호환성을 위해 유지하되 개선 export const createMockHandlers = (initialEvents: Event[] = []) => { - let mockEvents: Event[] = structuredClone(initialEvents); + return () => { + let mockEvents: Event[] = structuredClone(initialEvents); - return [ - http.get('/api/events', () => { - return HttpResponse.json({ events: mockEvents }, { status: 200 }); - }), + return [ + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), - http.post('/api/events', async ({ request }) => { - const newEvent = (await request.json()) as Omit; - const eventWithId: Event = { - ...newEvent, - id: Date.now().toString(), - }; - mockEvents.push(eventWithId); - return HttpResponse.json( - { - success: true, - event: eventWithId, - message: '일정이 성공적으로 추가되었습니다.', - }, - { status: 201 } - ); - }), + http.post('/api/events', async ({ request }) => { + const newEvent = (await request.json()) as Omit; + const eventWithId: Event = { + ...newEvent, + id: Date.now().toString(), + }; + mockEvents.push(eventWithId); + return HttpResponse.json( + { + success: true, + event: eventWithId, + message: '일정이 성공적으로 추가되었습니다.', + }, + { status: 201 } + ); + }), - http.put('/api/events/:id', async ({ request, params }) => { - const updatedEvent = (await request.json()) as Partial; - const index = mockEvents.findIndex((event) => event.id === params.id); + http.put('/api/events/:id', async ({ request, params }) => { + const updatedEvent = (await request.json()) as Partial; + const index = mockEvents.findIndex((event) => event.id === params.id); - if (index === -1) { - return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); - } + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } - mockEvents[index] = { ...mockEvents[index], ...updatedEvent, id: params.id as string }; - return HttpResponse.json( - { - success: true, - event: mockEvents[index], - message: '일정이 성공적으로 수정되었습니다.', - }, - { status: 200 } - ); - }), + mockEvents[index] = { ...mockEvents[index], ...updatedEvent, id: params.id as string }; + return HttpResponse.json( + { + success: true, + event: mockEvents[index], + message: '일정이 성공적으로 수정되었습니다.', + }, + { status: 200 } + ); + }), - http.delete('/api/events/:id', ({ params }) => { - const index = mockEvents.findIndex((event) => event.id === params.id); + http.delete('/api/events/:id', ({ params }) => { + const index = mockEvents.findIndex((event) => event.id === params.id); - if (index === -1) { - return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); - } + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } - const [deletedEvent] = mockEvents.splice(index, 1); - return HttpResponse.json({ event: deletedEvent }, { status: 200 }); - }), - ]; + const [deletedEvent] = mockEvents.splice(index, 1); + return HttpResponse.json({ event: deletedEvent }, { status: 200 }); + }), + ]; + }; }; From d668c7453e3a4a4aa79da78222e0dde21a1c790d Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:45:15 +0900 Subject: [PATCH 18/43] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__mocks__/handlersUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index 0f58452a..179c53d6 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -12,7 +12,6 @@ export const setupMockHandlerUpdating = (initEvents = [] as Event[]) => {}; export const setupMockHandlerDeletion = (initEvents = [] as Event[]) => {}; -// 기존 함수는 호환성을 위해 유지하되 개선 export const createMockHandlers = (initialEvents: Event[] = []) => { return () => { let mockEvents: Event[] = structuredClone(initialEvents); From 4f88758a7c7fd65e12471cb4281cb3ef4619e297 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:45:47 +0900 Subject: [PATCH 19/43] =?UTF-8?q?feat:=20medium=20useEventOperations=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=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 --- .../hooks/medium.useEventOperations.spec.ts | 260 +++++++++++++++++- 1 file changed, 247 insertions(+), 13 deletions(-) diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..c98bdd25 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -1,11 +1,7 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; -import { - setupMockHandlerCreation, - setupMockHandlerDeletion, - setupMockHandlerUpdating, -} from '../../__mocks__/handlersUtils.ts'; +import { events } from '../../__mocks__/response/events.json' assert { type: 'json' }; import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; import { Event } from '../../types.ts'; @@ -22,16 +18,254 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +describe('useEventOperations', () => { + beforeEach(() => { + vi.clearAllMocks(); + enqueueSnackbarFn.mockClear(); + }); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: events as Event[] }, { status: 200 }); + }) + ); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); + }); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + let mockEvents = [...(events as Event[])]; -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.post('/api/events', async ({ request }) => { + const eventData = await request.json(); + const eventWithId: Event = { + ...(eventData as Event), + id: 'new-id-123', + }; + mockEvents.push(eventWithId); + return HttpResponse.json( + { + success: true, + event: eventWithId, + message: '일정이 성공적으로 추가되었습니다.', + }, + { status: 201 } + ); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const newEvent: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-05-14', + startTime: '10:00', + endTime: '11:00', + description: '테스트 이벤트 설명', + location: '테스트 장소', + category: '테스트 카테고리', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + act(() => { + result.current.saveEvent(newEvent); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', { + variant: 'success', + }); + }); + }); + + it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + let mockEvents = [...(events as Event[])]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.put('/api/events/:id', async ({ request, params }) => { + const eventData = await request.json(); + const index = mockEvents.findIndex((event) => event.id === params.id); + + if (index !== -1) { + mockEvents[index] = { ...mockEvents[index], ...(eventData as Partial) }; + } + + return HttpResponse.json( + { + success: true, + event: mockEvents[index], + message: '일정이 성공적으로 수정되었습니다.', + }, + { status: 200 } + ); + }) + ); + + const { result } = renderHook(() => useEventOperations(true)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const event = events[0] as Event; + const updatedEvent: Event = { + ...event, + title: '수정된 이벤트', + endTime: '12:00', + }; + + act(() => { + result.current.saveEvent(updatedEvent); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', { + variant: 'success', + }); + }); + }); + + it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + let mockEvents = [...(events as Event[])]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.delete('/api/events/:id', ({ params }) => { + const index = mockEvents.findIndex((event) => event.id === params.id); + + if (index !== -1) { + mockEvents.splice(index, 1); + } + + return HttpResponse.json({ event: events[0] }, { status: 200 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const initialLength = result.current.events.length; + + act(() => { + result.current.deleteEvent(events[0].id); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { variant: 'info' }); + }); + + // 실제로 이벤트가 삭제되었는지 검증 + await waitFor(() => { + expect(result.current.events.length).toBe(initialLength - 1); + }); + }); + + it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); + }); + + expect(result.current.events).toEqual([]); + }); + + it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + let mockEvents = [...(events as Event[])]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.put('/api/events/:id', () => { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(true)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const event = events[0] as Event; + const updatedEvent: Event = { + ...event, + id: '2', + title: '수정된 이벤트', + }; + + act(() => { + result.current.saveEvent(updatedEvent); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); + }); + + expect(result.current.events).toEqual(events); + }); + + it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + let mockEvents = [...(events as Event[])]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.delete('/api/events/:id', () => { + return HttpResponse.json({ error: 'Network Error' }, { status: 500 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const initialLength = result.current.events.length; + + act(() => { + result.current.deleteEvent(events[0].id); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 삭제 실패', { variant: 'error' }); + }); + + // 삭제 실패 시 이벤트 개수가 변하지 않았는지 검증 + expect(result.current.events.length).toBe(initialLength); + }); +}); From 58af8dcc019db5e1af88c3c4dfb744a118b7234d Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:46:01 +0900 Subject: [PATCH 20/43] =?UTF-8?q?feat:=20useNotification=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=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 --- .../hooks/medium.useNotifications.spec.ts | 178 +++++++++++++++++- 1 file changed, 172 insertions(+), 6 deletions(-) diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts index 7f585ea8..f4561e8b 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -1,14 +1,180 @@ import { act, renderHook } from '@testing-library/react'; +import { vi } from 'vitest'; import { useNotifications } from '../../hooks/useNotifications.ts'; import { Event } from '../../types.ts'; -import { formatDate } from '../../utils/dateUtils.ts'; -import { parseHM } from '../utils.ts'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +// 테스트용 이벤트 데이터 +const createTestEvent = (overrides: Partial = {}): Event => ({ + id: '1', + title: '테스트 이벤트', + date: '2024-01-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, // 30분 전 알림 + ...overrides, +}); -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); +// 현재 시간을 모킹하는 헬퍼 함수 (setupTests.ts에서 이미 vi.useFakeTimers()가 설정됨) +const mockCurrentTime = (date: Date) => { + vi.setSystemTime(date); +}; -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); +it('초기 상태에서는 알림이 없어야 한다', () => { + const { result } = renderHook(() => useNotifications([])); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); + expect(result.current.notifications).toEqual([]); + expect(result.current.notifiedEvents).toEqual([]); +}); + +it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + const now = new Date('2024-01-01T09:30:00'); + mockCurrentTime(now); + + const event = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event])); + + expect(result.current.notifications).toEqual([]); + expect(result.current.notifiedEvents).toEqual([]); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifications[0].id).toBe('1'); + expect(result.current.notifications[0].message).toBe('30분 후 테스트 이벤트 일정이 시작됩니다.'); + + expect(result.current.notifiedEvents).toContain('1'); +}); + +it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const now = new Date('2024-01-01T09:30:00'); + mockCurrentTime(now); + + const event1 = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const event2 = createTestEvent({ + id: '2', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event1, event2])); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(2); + + act(() => { + result.current.removeNotification(0); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifications[0].id).toBe('2'); +}); + +it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + const now = new Date('2024-01-01T09:30:00'); + mockCurrentTime(now); + + const event = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event])); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toContain('1'); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toHaveLength(1); +}); + +it('알림 시간이 되지 않은 이벤트는 알림이 생성되지 않아야 한다', () => { + const now = new Date('2024-01-01T09:00:00'); // 9:00 + mockCurrentTime(now); + + const event = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event])); + + // 1초 후 알림 체크 + act(() => { + vi.advanceTimersByTime(1000); + }); + + // 아직 알림 시간이 되지 않았으므로 알림이 생성되지 않아야 함 + expect(result.current.notifications).toHaveLength(0); + expect(result.current.notifiedEvents).toHaveLength(0); +}); + +// 추가 테스트: 여러 이벤트가 동시에 알림 시간에 도달하는 경우 +it('여러 이벤트가 동시에 알림 시간에 도달하는 경우 모든 알림이 생성되어야 한다', () => { + const now = new Date('2024-01-01T09:30:00'); + mockCurrentTime(now); + + const event1 = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const event2 = createTestEvent({ + id: '2', + date: '2024-01-01', + startTime: '10:00', // 10:00으로 변경하여 동시에 알림 시간에 도달 + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event1, event2])); + + // 1초 후 알림 체크 + act(() => { + vi.advanceTimersByTime(1000); + }); + + // 두 개의 알림이 모두 생성되어야 함 + expect(result.current.notifications).toHaveLength(2); + expect(result.current.notifiedEvents).toHaveLength(2); + + // 알림 메시지 확인 + const notificationIds = result.current.notifications.map((n) => n.id); + expect(notificationIds).toContain('1'); + expect(notificationIds).toContain('2'); +}); From 63e3b55d5fbdeeffc31f60cd2ff797d08a6dcafe Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:46:11 +0900 Subject: [PATCH 21/43] =?UTF-8?q?feat:=20intergration=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 433 +++++++++++++--------- 1 file changed, 262 insertions(+), 171 deletions(-) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index cd835c7b..3f5a6ceb 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,14 +1,13 @@ import App from '../App'; import { server } from '../setupTests'; import { Event } from '../types'; -import { createMockHandlers } from '../__mocks__/handlersUtils'; +import { setupMockHandlerCreation, createMockHandlers } from '../__mocks__/handlersUtils'; import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act, waitFor } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { UserEvent, userEvent } from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; -import { ReactElement } from 'react'; import { vi } from 'vitest'; // 테스트용 테마 설정 @@ -26,7 +25,7 @@ const AppWrapper = () => ( describe('일정 CRUD 및 기본 기능', () => { it.only('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { - server.use(...createMockHandlers([])); + server.use(...createMockHandlers([])()); render(); @@ -34,7 +33,7 @@ describe('일정 CRUD 및 기본 기능', () => { const user = userEvent.setup() as UserEvent; // 일정 추가 버튼 클릭 - const addButton = screen.getByRole('button', { name: /일정 추가/i }); + const addButton = screen.getByRole('button', { name: '일정 추가' }); await user.click(addButton); await user.type(screen.getByLabelText('제목'), '테스트 회의'); @@ -44,7 +43,7 @@ describe('일정 CRUD 및 기본 기능', () => { await user.type(screen.getByLabelText('설명'), '테스트용 회의입니다'); await user.type(screen.getByLabelText('위치'), '온라인'); - const categorySelect = screen.getByLabelText('카테고리') || document.querySelector('#category'); + const categorySelect = screen.getByLabelText('카테고리'); // 카테고리 선택 await user.click(categorySelect); const categoryOption = screen.getByText('업무'); @@ -54,14 +53,10 @@ describe('일정 CRUD 및 기본 기능', () => { const saveButton = screen.getByTestId('event-submit-button'); await user.click(saveButton); - // 일정이 목록에 추가되었는지 확인 ( - await waitFor(() => { - const eventList = screen.getByTestId('event-list'); - expect(within(eventList).getByText('테스트 회의')).toBeInTheDocument(); - }); + // 일정이 목록에 추가되었는지 확인 + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('테스트 회의')).toBeInTheDocument(); - // 모든 필드가 정확히 저장되었는지 확인 - const eventList = screen.getByTestId('event-list'); expect(within(eventList).getByText('2025-10-01')).toBeInTheDocument(); expect(within(eventList).getByText('14:00 - 15:00')).toBeInTheDocument(); expect(within(eventList).getByText('테스트용 회의입니다')).toBeInTheDocument(); @@ -70,21 +65,20 @@ describe('일정 CRUD 및 기본 기능', () => { }); it.only('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { - server.use(...createMockHandlers(events as Event[])); + server.use(...createMockHandlers(events as Event[])()); render(); const user = userEvent.setup(); // 기존 일정이 표시되는지 확인 (일정 목록 내에서만 검색) - await waitFor(() => { - const eventList = screen.getByTestId('event-list'); - expect(within(eventList).getByText('기존 회의')).toBeInTheDocument(); - }); + + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('기존 회의')).toBeInTheDocument(); // 일정 수정 버튼 클릭 (첫 번째 일정의 Edit 버튼) - const editButtons = screen.getAllByRole('button', { name: /Edit event/i }); - await user.click(editButtons[0]); // 첫 번째 Edit 버튼 클릭 + const editButton = screen.getByRole('button', { name: 'Edit event' }); + await user.click(editButton); // 첫 번째 Edit 버튼 클릭 // 제목 수정 const titleInput = screen.getByDisplayValue('기존 회의'); @@ -101,11 +95,9 @@ describe('일정 CRUD 및 기본 기능', () => { await user.click(saveButton); // 수정된 내용이 반영되었는지 확인 (일정 목록 내에서만 검색) - await waitFor(() => { - const eventList = screen.getByTestId('event-list'); - expect(within(eventList).getByText('수정된 회의')).toBeInTheDocument(); - expect(within(eventList).getByText('수정된 팀 미팅')).toBeInTheDocument(); - }); + const eventEditList = await screen.findByTestId('event-list'); + expect(within(eventEditList).getByText('수정된 회의')).toBeInTheDocument(); + expect(within(eventEditList).getByText('수정된 팀 미팅')).toBeInTheDocument(); }); it.only('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { @@ -124,26 +116,22 @@ describe('일정 CRUD 및 기본 기능', () => { notificationTime: 10, }, ]; - server.use(...createMockHandlers(customEvents)); + server.use(...createMockHandlers(customEvents)()); render(); const user = userEvent.setup(); // 기존 일정이 표시되는지 확인 - const eventList = screen.getByTestId('event-list'); - await waitFor(() => { - expect(within(eventList).getByText('삭제할 이벤트')).toBeInTheDocument(); - }); + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('삭제할 이벤트')).toBeInTheDocument(); - // 삭제 버튼 클릭 (aria-label 사용) + // 삭제 버튼 클릭 ( const deleteButton = within(eventList).getByRole('button', { name: /Delete event/i }); await user.click(deleteButton); // 일정이 목록에서 제거되었는지 확인 - await waitFor(() => { - expect(within(eventList).queryByText('삭제할 이벤트')).not.toBeInTheDocument(); - }); + expect(within(eventList).queryByText('삭제할 이벤트')).not.toBeInTheDocument(); }); }); @@ -151,7 +139,7 @@ describe('일정 뷰', () => { it.only('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { vi.setSystemTime('2025-07-01'); - const testEvents: Event[] = [ + const mockEvents: Event[] = [ { id: '1', title: '7월 둘째주 회의', @@ -177,7 +165,7 @@ describe('일정 뷰', () => { notificationTime: 10, }, ]; - server.use(...createMockHandlers(testEvents)); + server.use(...createMockHandlers(mockEvents)()); render(); const user = userEvent.setup(); @@ -187,11 +175,9 @@ describe('일정 뷰', () => { await user.click(within(viewSelector).getByRole('combobox')); await user.click(screen.getByTestId('week-option')); - await waitFor(() => { - const eventList = screen.getByTestId('event-list'); - expect(within(eventList).queryByText('7월 둘째주 회의')).not.toBeInTheDocument(); - expect(within(eventList).queryByText('7월 둘째주 미팅')).not.toBeInTheDocument(); - }); + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).queryByText('7월 둘째주 회의')).not.toBeInTheDocument(); + expect(within(eventList).queryByText('7월 둘째주 미팅')).not.toBeInTheDocument(); vi.useRealTimers(); }); @@ -199,7 +185,7 @@ describe('일정 뷰', () => { it.only('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { vi.setSystemTime('2025-07-01'); - const testEvents: Event[] = [ + const mockEvents: Event[] = [ { id: '1', title: '7월 첫째주 회의', @@ -213,7 +199,7 @@ describe('일정 뷰', () => { notificationTime: 10, }, ]; - server.use(...createMockHandlers(testEvents)); + server.use(...createMockHandlers(mockEvents)()); render(); @@ -223,224 +209,329 @@ describe('일정 뷰', () => { await user.click(within(viewSelector).getByRole('combobox')); await user.click(screen.getByTestId('week-option')); - await waitFor(() => { - const eventList = screen.getByTestId('event-list'); - expect(within(eventList).queryByText('7월 첫째주 회의')).toBeInTheDocument(); - expect(within(eventList).queryByText('미팅미팅미팅~')).toBeInTheDocument(); - }); + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).queryByText('7월 첫째주 회의')).toBeInTheDocument(); + expect(within(eventList).queryByText('미팅미팅미팅~')).toBeInTheDocument(); vi.useRealTimers(); }); it.only('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { - // MSW Mock Server 설정 (빈 일정) - server.use(...createMockHandlers([])); + const mockEvents: Event[] = [ + { + id: '1', + title: '5월 첫째주 회의', + date: '2025-05-01', + startTime: '09:00', + endTime: '10:00', + description: '5월 회의입니당', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); render(); const user = userEvent.setup(); const viewSelector = screen.getByLabelText('뷰 타입 선택'); - await user.click(viewSelector); - - // Select가 열린 후 data-value로 Month 옵션 찾기 - const monthOption = screen.getByTestId('month-option'); - await user.click(monthOption); + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByTestId('month-option')); // 일정이 표시되지 않는지 확인 (일정 목록 컨테이너가 비어있거나 일정 데이터가 없는지 확인) - await waitFor(() => { - const eventList = screen.getByTestId('event-list'); - // 일정 목록 내에 실제 일정 데이터가 없는지 확인 - expect(within(eventList).queryByText('기존 회의')).not.toBeInTheDocument(); - expect(within(eventList).queryByText('기존 회의2')).not.toBeInTheDocument(); - expect(within(eventList).queryByText('삭제할 이벤트')).not.toBeInTheDocument(); - }); + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).queryByText('5월 첫째주 회의')).not.toBeInTheDocument(); + vi.useRealTimers(); }); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { - // MSW Mock Server 설정 (일정 있음) - server.use(...createMockHandlers(events as Event[])); + it.only('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '10월 첫째주 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '10월 회의입니당', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + + { + id: '2', + title: '10월 둘째주 회의', + date: '2025-10-08', + startTime: '09:00', + endTime: '10:00', + description: '회의 테스트2', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '3', + title: '10월 셋째주 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '회의 테스트333', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents as Event[])()); render(); const user = userEvent.setup(); - // 월별 뷰 선택 (aria-label을 사용하여 안정적인 셀렉터 사용) const viewSelector = screen.getByLabelText('뷰 타입 선택'); - await user.click(viewSelector); + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByTestId('month-option')); + + // 뷰 변경 후 모든 이벤트가 표시되는지 확인 + const eventList = screen.getByTestId('event-list'); - // Select가 열린 후 data-value로 Month 옵션 찾기 - const monthOption = screen.getByTestId('month-option'); - await user.click(monthOption); + expect(within(eventList).getByText('10월 첫째주 회의')).toBeInTheDocument(); + expect(within(eventList).getByText('10월 둘째주 회의')).toBeInTheDocument(); + expect(within(eventList).getByText('10월 셋째주 회의')).toBeInTheDocument(); - // 일정이 표시되는지 확인 - await waitFor(() => { - expect(screen.getByText('기존 회의')).toBeInTheDocument(); - expect(screen.getByText('기존 회의2')).toBeInTheDocument(); - }); + vi.useRealTimers(); }); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { - // MSW Mock Server 설정 - server.use(...createMockHandlers()); + it.only('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + vi.setSystemTime('2025-01-01'); render(); - // 1월 1일이 공휴일로 표시되는지 확인 - // (공휴일 표시 로직이 있다면 해당 요소를 찾아서 확인) - await waitFor(() => { - const newYearHoliday = screen.queryByText('신정') || screen.queryByText('1월 1일'); - // 공휴일 표시 여부는 실제 구현에 따라 다를 수 있음 - // expect(newYearHoliday).toBeInTheDocument(); - }); + const newYearHoliday = screen.queryByText('신정'); + expect(newYearHoliday).toBeInTheDocument(); + + vi.useRealTimers(); }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { - // MSW Mock Server 설정 (빈 일정) - server.use(...createMockHandlers()); + it.only('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + server.use(...createMockHandlers([])()); render(); const user = userEvent.setup(); - // 검색어 입력 const searchInput = screen.getByPlaceholderText(/검색어를 입력하세요/i); await user.type(searchInput, '존재하지 않는 일정'); - // 검색 결과가 없다는 메시지가 표시되는지 확인 - // (실제 UI에 해당 메시지가 있다면) - await waitFor(() => { - // expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); - }); + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); }); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { - // MSW Mock Server 설정 (특정 일정 포함) - server.use(...createMockHandlers()); + it.only("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '그냥 회의는 아니겟지', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); render(); const user = userEvent.setup(); - // 검색어 입력 - const searchInput = screen.getByPlaceholderText(/검색어를 입력하세요/i); + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); await user.type(searchInput, '팀 회의'); - // 검색 결과가 표시되는지 확인 - await waitFor(() => { - expect(screen.getByText('기존 회의')).toBeInTheDocument(); - expect(screen.getByText('기존 회의2')).toBeInTheDocument(); - }); + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('팀 회의')).toBeInTheDocument(); }); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { - // MSW Mock Server 설정 (일정 있음) - server.use(...createMockHandlers()); + it.only('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '그냥 회의는 아니겟지', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '팀 회의2', + date: '2025-10-29', + startTime: '09:00', + endTime: '10:00', + description: '그냥 회의는 아니겟지2', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '3', + title: '기존 회의1', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 회의는 아니겟지1', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '4', + title: '기존 회의2', + date: '2025-10-20', + startTime: '09:00', + endTime: '10:00', + description: '기존 회의는 아니겟지2', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); render(); const user = userEvent.setup(); - // 검색어 입력 - const searchInput = screen.getByPlaceholderText(/검색어를 입력하세요/i); + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); await user.type(searchInput, '팀 회의'); - // 검색어 지우기 await user.clear(searchInput); - // 모든 일정이 다시 표시되는지 확인 - await waitFor(() => { - expect(screen.getByText('기존 회의')).toBeInTheDocument(); - expect(screen.getByText('기존 회의2')).toBeInTheDocument(); - }); + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('기존 회의1')).toBeInTheDocument(); + expect(within(eventList).getByText('기존 회의2')).toBeInTheDocument(); + expect(within(eventList).getByText('팀 회의')).toBeInTheDocument(); + expect(within(eventList).getByText('팀 회의2')).toBeInTheDocument(); }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { - // MSW Mock Server 설정 (기존 일정 있음) - server.use(...createMockHandlers()); + it.only('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + server.use(...createMockHandlers(events as Event[])()); render(); const user = userEvent.setup(); - // 일정 추가 버튼 클릭 - const addButton = screen.getByRole('button', { name: /일정 추가/i }); + const addButton = screen.getByRole('button', { name: '일정 추가' }); await user.click(addButton); - // 겹치는 시간으로 일정 작성 - const titleInput = screen.getByLabelText(/제목/i); - const dateInput = screen.getByLabelText(/날짜/i); - const startTimeInput = screen.getByLabelText(/시작 시간/i); - const endTimeInput = screen.getByLabelText(/종료 시간/i); + await user.type(screen.getByLabelText('제목'), '충돌하는 일정'); + await user.type(screen.getByLabelText('날짜'), '2025-10-15'); // 기존 일정과 같은 날짜 + await user.type(screen.getByLabelText('시작 시간'), '09:30'); // 기존 일정과 겹치는 시간 + await user.type(screen.getByLabelText('종료 시간'), '10:30'); // 기존 일정과 겹치는 시간 + await user.type(screen.getByLabelText('설명'), '충돌 테스트'); + await user.type(screen.getByLabelText('위치'), '회의실 A'); - await user.type(titleInput, '충돌하는 일정'); - await user.type(dateInput, '2025-08-15'); // 기존 일정과 같은 날짜 - await user.type(startTimeInput, '09:30'); // 기존 일정과 겹치는 시간 - await user.type(endTimeInput, '10:30'); + const categorySelect = screen.getByLabelText('카테고리'); + await user.click(categorySelect); + const categoryOption = screen.getByText('업무'); + await user.click(categoryOption); - // 저장 버튼 클릭 - const saveButton = screen.getByRole('button', { name: /일정 추가/i }); + const saveButton = screen.getByTestId('event-submit-button'); await user.click(saveButton); - // 시간 충돌 경고가 표시되는지 확인 - await waitFor(() => { - expect(screen.getByText(/시간 충돌|겹치는 시간|충돌/i)).toBeInTheDocument(); - }); + const submitButton = screen.getByTestId('event-submit-button'); + await user.click(submitButton); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); }); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { - // MSW Mock Server 설정 (여러 일정) - server.use(...createMockHandlers()); + it.only('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + const existingEvents: Event[] = [ + { + id: '1', + title: '첫 번째 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '두 번째 회의', + date: '2025-10-15', + startTime: '11:00', + endTime: '12:00', + description: '두 번째 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + + server.use(...createMockHandlers(existingEvents)()); render(); const user = userEvent.setup(); - // 기존 일정 수정 (실제 App UI에 맞춰서) - const editButton = screen.getByRole('button', { name: /Edit event/i }); - await user.click(editButton); + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); - // 겹치는 시간으로 수정 - const startTimeInput = screen.getByDisplayValue('09:00'); - await user.clear(startTimeInput); - await user.type(startTimeInput, '11:30'); // 다른 일정과 겹치는 시간 + await user.click(editButtons[0]); + await user.clear(screen.getByLabelText('시작 시간')); + await user.type(screen.getByLabelText('시작 시간'), '11:30'); + await user.clear(screen.getByLabelText('종료 시간')); + await user.type(screen.getByLabelText('종료 시간'), '11:40'); - // 저장 버튼 클릭 - const saveButton = screen.getByRole('button', { name: /일정 추가/i }); + const saveButton = screen.getByTestId('event-submit-button'); await user.click(saveButton); - // 시간 충돌 경고가 표시되는지 확인 - await waitFor(() => { - expect(screen.getByText(/시간 충돌|겹치는 시간|충돌/i)).toBeInTheDocument(); - }); + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { - // MSW Mock Server 설정 - server.use(...createMockHandlers()); - +it.only('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + const newEvent: Event = { + id: '1', + title: '새로운 이벤트', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '마지막 테스트..', + location: '불꺼진 우리집', + category: '항플 과제', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + server.use(...createMockHandlers([newEvent])()); render(); - const user = userEvent.setup(); - - // 일정 추가 버튼 클릭 - const addButton = screen.getByRole('button', { name: /일정 추가/i }); - await user.click(addButton); + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText(newEvent.title)).toBeInTheDocument(); - // 알림 시간을 10분으로 설정 (실제 App UI에 맞춰서) - const notificationSelect = - screen.getByLabelText(/notification/i) || document.querySelector('#notification'); - await user.click(notificationSelect); - const notificationOption = screen.getByText('10분 전'); - await user.click(notificationOption); - - // 알림 텍스트가 표시되는지 확인 - await waitFor(() => { - expect(screen.getByText('10분 전')).toBeInTheDocument(); - }); + vi.setSystemTime('2025-10-01 09:50'); + expect(await screen.findByText('10분 전')).toBeInTheDocument(); }); From 69c21d9d235fd6431947d401d3969120574e4569 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:47:54 +0900 Subject: [PATCH 22/43] =?UTF-8?q?chore:=20only=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 3f5a6ceb..0639027a 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -24,7 +24,7 @@ const AppWrapper = () => ( ); describe('일정 CRUD 및 기본 기능', () => { - it.only('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { + it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { server.use(...createMockHandlers([])()); render(); @@ -64,7 +64,7 @@ describe('일정 CRUD 및 기본 기능', () => { expect(within(eventList).getByText('카테고리: 업무')).toBeInTheDocument(); }); - it.only('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { server.use(...createMockHandlers(events as Event[])()); render(); @@ -100,7 +100,7 @@ describe('일정 CRUD 및 기본 기능', () => { expect(within(eventEditList).getByText('수정된 팀 미팅')).toBeInTheDocument(); }); - it.only('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { // MSW Mock Server 설정 (삭제용 - 내장된 mock 데이터 사용) const customEvents: Event[] = [ { @@ -136,7 +136,7 @@ describe('일정 CRUD 및 기본 기능', () => { }); describe('일정 뷰', () => { - it.only('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { vi.setSystemTime('2025-07-01'); const mockEvents: Event[] = [ @@ -182,7 +182,7 @@ describe('일정 뷰', () => { vi.useRealTimers(); }); - it.only('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { vi.setSystemTime('2025-07-01'); const mockEvents: Event[] = [ @@ -216,7 +216,7 @@ describe('일정 뷰', () => { vi.useRealTimers(); }); - it.only('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { const mockEvents: Event[] = [ { id: '1', @@ -247,7 +247,7 @@ describe('일정 뷰', () => { vi.useRealTimers(); }); - it.only('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { const mockEvents: Event[] = [ { id: '1', @@ -307,7 +307,7 @@ describe('일정 뷰', () => { vi.useRealTimers(); }); - it.only('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { vi.setSystemTime('2025-01-01'); render(); @@ -320,7 +320,7 @@ describe('일정 뷰', () => { }); describe('검색 기능', () => { - it.only('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { server.use(...createMockHandlers([])()); render(); @@ -333,7 +333,7 @@ describe('검색 기능', () => { expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); }); - it.only("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { const mockEvents: Event[] = [ { id: '1', @@ -361,7 +361,7 @@ describe('검색 기능', () => { expect(within(eventList).getByText('팀 회의')).toBeInTheDocument(); }); - it.only('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { const mockEvents: Event[] = [ { id: '1', @@ -432,7 +432,7 @@ describe('검색 기능', () => { }); describe('일정 충돌', () => { - it.only('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { server.use(...createMockHandlers(events as Event[])()); render(); @@ -463,7 +463,7 @@ describe('일정 충돌', () => { expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); }); - it.only('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { const existingEvents: Event[] = [ { id: '1', @@ -512,7 +512,7 @@ describe('일정 충돌', () => { }); }); -it.only('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { const newEvent: Event = { id: '1', title: '새로운 이벤트', From c9bc059395944694a796da23bcb840601399ddf7 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:49:39 +0900 Subject: [PATCH 23/43] =?UTF-8?q?chore:=20@eslint/js=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 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index e7d2cbb1..455c4f2e 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-dom": "19.1.0" }, "devDependencies": { + "@eslint/js": "^9.33.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.5.2", From 9f1b1b106a28c7b564e9428d003292ddb5cd255f Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:50:54 +0900 Subject: [PATCH 24/43] chore --- pnpm-lock.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093f3ec7..bd406e25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: specifier: 19.1.0 version: 19.1.0(react@19.1.0) devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -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 eeab751062bdb2cd3e78722f3ad3c2af7095417d Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 04:52:36 +0900 Subject: [PATCH 25/43] chore: update ESLint configuration to disable no-unused-vars and enforce @typescript-eslint/no-unused-vars --- eslint.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 0a019971..7fbdd630 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', @@ -118,6 +119,7 @@ export default [ globalThis: 'readonly', describe: 'readonly', it: 'readonly', + test: 'readonly', expect: 'readonly', beforeEach: 'readonly', afterEach: 'readonly', From e0dd5c8ffc3ae2fc108cafc5d77a04c93b9cfa70 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 05:03:17 +0900 Subject: [PATCH 26/43] =?UTF-8?q?chore:=20=EB=A6=B0=ED=8A=B8=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.js | 2 +- src/__mocks__/handlersUtils.ts | 8 +------- src/__tests__/medium.integration.spec.tsx | 11 ++++++----- src/hooks/useEventOperations.ts | 1 - src/setupTests.ts | 3 ++- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 7fbdd630..d811750b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -76,6 +76,7 @@ export default [ // React rules 'react/prop-types': 'off', ...reactHooksPlugin.configs.recommended.rules, + 'react-hooks/exhaustive-deps': 'off', // Import rules 'import/order': [ @@ -119,7 +120,6 @@ export default [ globalThis: 'readonly', describe: 'readonly', it: 'readonly', - test: 'readonly', expect: 'readonly', beforeEach: 'readonly', afterEach: 'readonly', diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index 179c53d6..373504b3 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -1,17 +1,11 @@ import { http, HttpResponse } from 'msw'; + import { Event } from '../types'; // ! Hard // ! 이벤트는 생성, 수정 되면 fetch를 다시 해 상태를 업데이트 합니다. 이를 위한 제어가 필요할 것 같은데요. 어떻게 작성해야 테스트가 병렬로 돌아도 안정적이게 동작할까요? // ! 아래 이름을 사용하지 않아도 되니, 독립적이게 테스트를 구동할 수 있는 방법을 찾아보세요. 그리고 이 로직을 PR에 설명해주세요. -// 각 테스트마다 독립적인 상태를 가지도록 클로저를 사용 -export const setupMockHandlerCreation = (initEvents = [] as Event[]) => {}; - -export const setupMockHandlerUpdating = (initEvents = [] as Event[]) => {}; - -export const setupMockHandlerDeletion = (initEvents = [] as Event[]) => {}; - export const createMockHandlers = (initialEvents: Event[] = []) => { return () => { let mockEvents: Event[] = structuredClone(initialEvents); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 0639027a..48c01ddd 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,8 +1,3 @@ -import App from '../App'; -import { server } from '../setupTests'; -import { Event } from '../types'; -import { setupMockHandlerCreation, createMockHandlers } from '../__mocks__/handlersUtils'; -import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import { render, screen, within } from '@testing-library/react'; @@ -10,6 +5,12 @@ import { UserEvent, userEvent } from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { vi } from 'vitest'; +import { createMockHandlers } from '../__mocks__/handlersUtils'; +import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; +import App from '../App'; +import { server } from '../setupTests'; +import { Event } from '../types'; + // 테스트용 테마 설정 const theme = createTheme(); diff --git a/src/hooks/useEventOperations.ts b/src/hooks/useEventOperations.ts index 884d600b..39bec6e2 100644 --- a/src/hooks/useEventOperations.ts +++ b/src/hooks/useEventOperations.ts @@ -77,7 +77,6 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { useEffect(() => { init(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { events, fetchEvents, saveEvent, deleteEvent }; diff --git a/src/setupTests.ts b/src/setupTests.ts index e39f8fac..3cb1bc79 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,5 +1,6 @@ -import { setupServer } from 'msw/node'; import '@testing-library/jest-dom'; +import { setupServer } from 'msw/node'; + import { handlers } from './__mocks__/handlers'; export const server = setupServer(...handlers); From ec65e7c2d57808a6c01c468de499e7d433c8c349 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 05:08:32 +0900 Subject: [PATCH 27/43] =?UTF-8?q?refactor:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 53 +++++++++++++---------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 48c01ddd..5937eac8 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -513,26 +513,35 @@ describe('일정 충돌', () => { }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { - const newEvent: Event = { - id: '1', - title: '새로운 이벤트', - date: '2025-10-01', - startTime: '09:00', - endTime: '10:00', - description: '마지막 테스트..', - location: '불꺼진 우리집', - category: '항플 과제', - repeat: { type: 'none', interval: 0 }, - notificationTime: 10, - }; - - server.use(...createMockHandlers([newEvent])()); - render(); - - const eventList = await screen.findByTestId('event-list'); - expect(within(eventList).getByText(newEvent.title)).toBeInTheDocument(); - - vi.setSystemTime('2025-10-01 09:50'); - expect(await screen.findByText('10분 전')).toBeInTheDocument(); +describe('알림 기능', () => { + it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + const newEvent: Event = { + id: '1', + title: '새로운 이벤트', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '마지막 테스트..', + location: '불꺼진 우리집', + category: '항플 과제', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + server.use(...createMockHandlers([newEvent])()); + render(); + + // 이벤트가 실제로 렌더링될 때까지 기다림 + const eventList = await screen.findByTestId('event-list'); + + // 일정 목록에서만 이벤트 제목을 찾음 + const eventTitle = within(eventList).getByText(newEvent.title); + expect(eventTitle).toBeInTheDocument(); + + // 시스템 시간을 설정하고 알림 텍스트 확인 + vi.setSystemTime('2025-10-01 09:50'); + + // 알림 텍스트가 나타날 때까지 기다림 + expect(await screen.findByText('10분 전')).toBeInTheDocument(); + }); }); From e6c3ea1de5ede6712677caa50986a71858b2697f Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 05:52:14 +0900 Subject: [PATCH 28/43] =?UTF-8?q?feat:=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 636 ++++---------------------- src/components/CalendarNavigation.tsx | 35 ++ src/components/EventCard.tsx | 63 +++ src/components/EventForm.tsx | 211 +++++++++ src/components/EventList.tsx | 53 +++ src/components/MonthView.tsx | 119 +++++ src/components/NotificationStack.tsx | 36 ++ src/components/OverlapDialog.tsx | 42 ++ src/components/WeekView.tsx | 98 ++++ 9 files changed, 745 insertions(+), 548 deletions(-) create mode 100644 src/components/CalendarNavigation.tsx create mode 100644 src/components/EventCard.tsx create mode 100644 src/components/EventForm.tsx create mode 100644 src/components/EventList.tsx create mode 100644 src/components/MonthView.tsx create mode 100644 src/components/NotificationStack.tsx create mode 100644 src/components/OverlapDialog.tsx create mode 100644 src/components/WeekView.tsx diff --git a/src/App.tsx b/src/App.tsx index 47318393..1ea2dc89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,32 +1,12 @@ -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 { Box, Stack, Typography } from '@mui/material'; +import { EventForm } from './components/EventForm'; +import { WeekView } from './components/WeekView'; +import { MonthView } from './components/MonthView'; +import { CalendarNavigation } from './components/CalendarNavigation'; +import { EventList } from './components/EventList'; +import { OverlapDialog } from './components/OverlapDialog'; +import { NotificationStack } from './components/NotificationStack'; + import { useSnackbar } from 'notistack'; import { useState } from 'react'; @@ -36,29 +16,9 @@ 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 = ['업무', '개인', '가족', '기타']; +import { Event, EventForm as EventFormType } from './types'; -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 { findOverlappingEvents } from './utils/eventOverlap'; function App() { const { @@ -118,7 +78,7 @@ function App() { return; } - const eventData: Event | EventForm = { + const eventData: Event | EventFormType = { id: editingEvent ? editingEvent.id : undefined, title, date, @@ -145,515 +105,95 @@ function App() { } }; - 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 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} - - - - ); - })} - - )} - - ); - })} - - ))} - -
-
-
- ); - }; - 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)} - /> - - - - )} */} - - - + 일정 보기 - - navigate('prev')}> - - - - navigate('next')}> - - - - - {view === 'week' && renderWeekView()} - {view === 'month' && renderMonthView()} - + - - - 일정 검색 - setSearchTerm(e.target.value)} + {view === 'week' && ( + + )} + {view === 'month' && ( + - - - {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={() => { + setIsOverlapDialogOpen(false); + saveEvent({ + id: editingEvent ? editingEvent.id : undefined, + title, + date, + startTime, + endTime, + description, + location, + category, + repeat: { + type: isRepeating ? repeatType : 'none', + interval: repeatInterval, + endDate: repeatEndDate || undefined, + }, + notificationTime, + }); + }} + /> + + setNotifications((prev) => prev.filter((_, i) => i !== index))} + /> ); } diff --git a/src/components/CalendarNavigation.tsx b/src/components/CalendarNavigation.tsx new file mode 100644 index 00000000..d3389485 --- /dev/null +++ b/src/components/CalendarNavigation.tsx @@ -0,0 +1,35 @@ +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { IconButton, MenuItem, Select, Stack } from '@mui/material'; + +interface CalendarNavigationProps { + view: 'week' | 'month'; + setView: (view: 'week' | 'month') => void; + navigate: (direction: 'prev' | 'next') => void; +} + +export function CalendarNavigation({ view, setView, navigate }: CalendarNavigationProps) { + return ( + + navigate('prev')}> + + + + navigate('next')}> + + + + ); +} diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx new file mode 100644 index 00000000..5abcb733 --- /dev/null +++ b/src/components/EventCard.tsx @@ -0,0 +1,63 @@ +import { Delete, Edit, Notifications } from '@mui/icons-material'; +import { Box, IconButton, Stack, Typography } from '@mui/material'; +import { Event } from '../types'; + +interface EventCardProps { + event: Event; + isNotified: boolean; + onEdit: (event: Event) => void; + onDelete: (id: string) => void; +} + +export function EventCard({ event, isNotified, onEdit, onDelete }: EventCardProps) { + return ( + + + + + {isNotified && } + + {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})`} + + )} + + 알림: {event.notificationTime === 1 && '1분 전'} + {event.notificationTime === 10 && '10분 전'} + {event.notificationTime === 60 && '1시간 전'} + {event.notificationTime === 120 && '2시간 전'} + {event.notificationTime === 1440 && '1일 전'} + + + + onEdit(event)}> + + + onDelete(event.id)}> + + + + + + ); +} diff --git a/src/components/EventForm.tsx b/src/components/EventForm.tsx new file mode 100644 index 00000000..ec93b5df --- /dev/null +++ b/src/components/EventForm.tsx @@ -0,0 +1,211 @@ +import { + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { EventForm as EventFormType } from '../types'; +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 { + title: string; + setTitle: (title: string) => void; + date: string; + setDate: (date: string) => void; + startTime: string; + endTime: string; + description: string; + setDescription: (description: string) => void; + location: string; + setLocation: (location: string) => void; + category: string; + setCategory: (category: string) => void; + isRepeating: boolean; + setIsRepeating: (isRepeating: boolean) => void; + notificationTime: number; + setNotificationTime: (notificationTime: number) => void; + startTimeError: string | null; + endTimeError: string | null; + editingEvent: EventFormType | null; + handleStartTimeChange: (e: React.ChangeEvent) => void; + handleEndTimeChange: (e: React.ChangeEvent) => void; + addOrUpdateEvent: () => void; +} + +export function EventForm({ + title, + setTitle, + date, + setDate, + startTime, + endTime, + description, + setDescription, + location, + setLocation, + category, + setCategory, + isRepeating, + setIsRepeating, + notificationTime, + setNotificationTime, + startTimeError, + endTimeError, + editingEvent, + handleStartTimeChange, + handleEndTimeChange, + addOrUpdateEvent, +}: EventFormProps) { + 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="반복 일정" + /> + + + + 알림 설정 + + + + + + ); +} diff --git a/src/components/EventList.tsx b/src/components/EventList.tsx new file mode 100644 index 00000000..4116b634 --- /dev/null +++ b/src/components/EventList.tsx @@ -0,0 +1,53 @@ +import { Stack, TextField, Typography } from '@mui/material'; +import { Event } from '../types'; +import { EventCard } from './EventCard'; + +interface EventListProps { + searchTerm: string; + setSearchTerm: (term: string) => void; + filteredEvents: Event[]; + notifiedEvents: string[]; + editEvent: (event: Event) => void; + deleteEvent: (id: string) => void; +} + +export function EventList({ + searchTerm, + setSearchTerm, + filteredEvents, + notifiedEvents, + editEvent, + deleteEvent, +}: EventListProps) { + return ( + + setSearchTerm(e.target.value)} + label="일정 검색" + fullWidth + /> + + {filteredEvents.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + filteredEvents.map((event) => ( + + )) + )} + + ); +} diff --git a/src/components/MonthView.tsx b/src/components/MonthView.tsx new file mode 100644 index 00000000..79b548ca --- /dev/null +++ b/src/components/MonthView.tsx @@ -0,0 +1,119 @@ +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, getEventsForDay, getWeeksAtMonth } from '../utils/dateUtils'; + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +interface MonthViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; + holidays: Record; +} + +export function MonthView({ + currentDate, + filteredEvents, + notifiedEvents, + holidays, +}: MonthViewProps) { + 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} + + + + ); + })} + + )} + + ); + })} + + ))} + +
+
+
+ ); +} diff --git a/src/components/NotificationStack.tsx b/src/components/NotificationStack.tsx new file mode 100644 index 00000000..0c706bdf --- /dev/null +++ b/src/components/NotificationStack.tsx @@ -0,0 +1,36 @@ +import { Close } from '@mui/icons-material'; +import { Alert, AlertTitle, IconButton, Stack } from '@mui/material'; + +interface Notification { + message: string; +} + +interface NotificationStackProps { + notifications: Notification[]; + onClose: (index: number) => void; +} + +export function NotificationStack({ notifications, onClose }: NotificationStackProps) { + if (notifications.length === 0) { + return null; + } + + return ( + + {notifications.map((notification, index) => ( + onClose(index)}> + + + } + > + {notification.message} + + ))} + + ); +} diff --git a/src/components/OverlapDialog.tsx b/src/components/OverlapDialog.tsx new file mode 100644 index 00000000..9731cd73 --- /dev/null +++ b/src/components/OverlapDialog.tsx @@ -0,0 +1,42 @@ +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 function OverlapDialog({ open, overlappingEvents, onClose, onConfirm }: OverlapDialogProps) { + return ( + + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +} diff --git a/src/components/WeekView.tsx b/src/components/WeekView.tsx new file mode 100644 index 00000000..2149c6cc --- /dev/null +++ b/src/components/WeekView.tsx @@ -0,0 +1,98 @@ +import { Notifications } from '@mui/icons-material'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { Event } from '../types'; +import { formatWeek, getWeekDates } from '../utils/dateUtils'; + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +interface WeekViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; +} + +export function WeekView({ currentDate, filteredEvents, notifiedEvents }: WeekViewProps) { + 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} + + + + ); + })} + + ))} + + +
+
+
+ ); +} From a297e0ad323daa1a353e756ce211a5671488fff8 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 06:08:00 +0900 Subject: [PATCH 29/43] feat: refactor event management and introduce CalendarLayout component for improved UI structure --- src/App.tsx | 139 +++++++++--------------------- src/components/CalendarLayout.tsx | 49 +++++++++++ src/hooks/useEventManagement.ts | 103 ++++++++++++++++++++++ src/utils/eventDataBuilder.ts | 34 ++++++++ 4 files changed, 229 insertions(+), 96 deletions(-) create mode 100644 src/components/CalendarLayout.tsx create mode 100644 src/hooks/useEventManagement.ts create mode 100644 src/utils/eventDataBuilder.ts diff --git a/src/App.tsx b/src/App.tsx index 1ea2dc89..2af5522d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,16 @@ -import { Box, Stack, Typography } from '@mui/material'; +import { Box, Stack } from '@mui/material'; import { EventForm } from './components/EventForm'; -import { WeekView } from './components/WeekView'; -import { MonthView } from './components/MonthView'; -import { CalendarNavigation } from './components/CalendarNavigation'; import { EventList } from './components/EventList'; import { OverlapDialog } from './components/OverlapDialog'; import { NotificationStack } from './components/NotificationStack'; - -import { useSnackbar } from 'notistack'; -import { useState } from 'react'; +import { CalendarLayout } from './components/CalendarLayout'; 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 as EventFormType } from './types'; - -import { findOverlappingEvents } from './utils/eventOverlap'; +import { useEventManagement } from './hooks/useEventManagement.ts'; function App() { const { @@ -37,11 +29,8 @@ function App() { isRepeating, setIsRepeating, repeatType, - // setRepeatType, repeatInterval, - // setRepeatInterval, repeatEndDate, - // setRepeatEndDate, notificationTime, setNotificationTime, startTimeError, @@ -62,49 +51,37 @@ function App() { const { view, setView, currentDate, holidays, navigate } = useCalendarView(); const { searchTerm, filteredEvents, setSearchTerm } = useSearch(events, currentDate, view); - 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 | 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) { - setOverlappingEvents(overlapping); - setIsOverlapDialogOpen(true); - } else { - await saveEvent(eventData); - resetForm(); - } + const formData = { + title, + date, + startTime, + endTime, + description, + location, + category, + isRepeating, + repeatType, + repeatInterval, + repeatEndDate, + notificationTime, + startTimeError: startTimeError || '', + endTimeError: endTimeError || '', + editingEvent, }; + const { + isOverlapDialogOpen, + overlappingEvents, + addOrUpdateEvent, + handleOverlapConfirm, + closeOverlapDialog, + } = useEventManagement({ + events, + saveEvent, + resetForm, + formData, + }); + return ( @@ -133,27 +110,15 @@ function App() { addOrUpdateEvent={addOrUpdateEvent} /> - - 일정 보기 - - - - {view === 'week' && ( - - )} - {view === 'month' && ( - - )} - + setIsOverlapDialogOpen(false)} - onConfirm={() => { - setIsOverlapDialogOpen(false); - saveEvent({ - id: editingEvent ? editingEvent.id : undefined, - title, - date, - startTime, - endTime, - description, - location, - category, - repeat: { - type: isRepeating ? repeatType : 'none', - interval: repeatInterval, - endDate: repeatEndDate || undefined, - }, - notificationTime, - }); - }} + onClose={closeOverlapDialog} + onConfirm={handleOverlapConfirm} /> void; + navigate: (direction: 'prev' | 'next') => void; + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; + holidays: Record; +} + +export const CalendarLayout = ({ + view, + setView, + navigate, + currentDate, + filteredEvents, + notifiedEvents, + holidays, +}: CalendarLayoutProps) => { + return ( + + 일정 보기 + + + + {view === 'week' && ( + + )} + {view === 'month' && ( + + )} + + ); +}; diff --git a/src/hooks/useEventManagement.ts b/src/hooks/useEventManagement.ts new file mode 100644 index 00000000..957b722f --- /dev/null +++ b/src/hooks/useEventManagement.ts @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { useSnackbar } from 'notistack'; +import { Event, EventForm as EventFormType, RepeatType } from '../types'; +import { findOverlappingEvents } from '../utils/eventOverlap'; + +interface UseEventManagementProps { + events: Event[]; + saveEvent: (event: Event | EventFormType) => Promise; + resetForm: () => void; + formData: { + title: string; + date: string; + startTime: string; + endTime: string; + description: string; + location: string; + category: string; + isRepeating: boolean; + repeatType: RepeatType; + repeatInterval: number; + repeatEndDate: string | null; + notificationTime: number; + startTimeError: string; + endTimeError: string; + editingEvent: Event | null; + }; +} + +export const useEventManagement = ({ + events, + saveEvent, + resetForm, + formData, +}: UseEventManagementProps) => { + const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); + const [overlappingEvents, setOverlappingEvents] = useState([]); + const { enqueueSnackbar } = useSnackbar(); + + const createEventData = (): Event | EventFormType => ({ + id: formData.editingEvent ? formData.editingEvent.id : undefined, + title: formData.title, + date: formData.date, + startTime: formData.startTime, + endTime: formData.endTime, + description: formData.description, + location: formData.location, + category: formData.category, + repeat: { + type: formData.isRepeating ? formData.repeatType : 'none', + interval: formData.repeatInterval, + endDate: formData.repeatEndDate || undefined, + }, + notificationTime: formData.notificationTime, + }); + + const validateForm = (): boolean => { + if (!formData.title || !formData.date || !formData.startTime || !formData.endTime) { + enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); + return false; + } + + if (formData.startTimeError || formData.endTimeError) { + enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); + return false; + } + + return true; + }; + + const addOrUpdateEvent = async () => { + if (!validateForm()) return; + + const eventData = createEventData(); + const overlapping = findOverlappingEvents(eventData, events); + + if (overlapping.length > 0) { + setOverlappingEvents(overlapping); + setIsOverlapDialogOpen(true); + } else { + await saveEvent(eventData); + resetForm(); + } + }; + + const handleOverlapConfirm = async () => { + setIsOverlapDialogOpen(false); + const eventData = createEventData(); + await saveEvent(eventData); + resetForm(); + }; + + const closeOverlapDialog = () => { + setIsOverlapDialogOpen(false); + }; + + return { + isOverlapDialogOpen, + overlappingEvents, + addOrUpdateEvent, + handleOverlapConfirm, + closeOverlapDialog, + }; +}; diff --git a/src/utils/eventDataBuilder.ts b/src/utils/eventDataBuilder.ts new file mode 100644 index 00000000..ae5f7c0c --- /dev/null +++ b/src/utils/eventDataBuilder.ts @@ -0,0 +1,34 @@ +import { Event, EventForm as EventFormType, RepeatType } from '../types'; + +interface EventFormData { + id?: string; + title: string; + date: string; + startTime: string; + endTime: string; + description: string; + location: string; + category: string; + isRepeating: boolean; + repeatType: RepeatType; + repeatInterval: number; + repeatEndDate: string | null; + notificationTime: number; +} + +export const buildEventData = (formData: EventFormData): Event | EventFormType => ({ + id: formData.id, + title: formData.title, + date: formData.date, + startTime: formData.startTime, + endTime: formData.endTime, + description: formData.description, + location: formData.location, + category: formData.category, + repeat: { + type: formData.isRepeating ? formData.repeatType : 'none', + interval: formData.repeatInterval, + endDate: formData.repeatEndDate || undefined, + }, + notificationTime: formData.notificationTime, +}); From 2cf34838e23608d7ef849e88a67250ce70be8bf6 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 06:20:30 +0900 Subject: [PATCH 30/43] feat: add calendar and event components, refactor imports for better organization --- src/App.tsx | 12 +++++++----- src/components/{ => calendar}/CalendarLayout.tsx | 6 ++---- src/components/{ => calendar}/CalendarNavigation.tsx | 0 src/components/{ => calendar}/MonthView.tsx | 4 ++-- src/components/{ => calendar}/WeekView.tsx | 4 ++-- src/components/calendar/index.ts | 4 ++++ src/components/{ => event}/EventCard.tsx | 2 +- src/components/{ => event}/EventList.tsx | 2 +- src/components/{ => event}/OverlapDialog.tsx | 2 +- src/components/event/index.ts | 3 +++ src/components/{ => form}/EventForm.tsx | 4 ++-- src/components/form/index.ts | 1 + src/components/index.ts | 11 +++++++++++ .../{ => notification}/NotificationStack.tsx | 0 src/components/notification/index.ts | 1 + 15 files changed, 38 insertions(+), 18 deletions(-) rename src/components/{ => calendar}/CalendarLayout.tsx (86%) rename src/components/{ => calendar}/CalendarNavigation.tsx (100%) rename src/components/{ => calendar}/MonthView.tsx (98%) rename src/components/{ => calendar}/WeekView.tsx (96%) create mode 100644 src/components/calendar/index.ts rename src/components/{ => event}/EventCard.tsx (98%) rename src/components/{ => event}/EventList.tsx (97%) rename src/components/{ => event}/OverlapDialog.tsx (96%) create mode 100644 src/components/event/index.ts rename src/components/{ => form}/EventForm.tsx (97%) create mode 100644 src/components/form/index.ts create mode 100644 src/components/index.ts rename src/components/{ => notification}/NotificationStack.tsx (100%) create mode 100644 src/components/notification/index.ts diff --git a/src/App.tsx b/src/App.tsx index 2af5522d..ba393136 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,11 @@ import { Box, Stack } from '@mui/material'; -import { EventForm } from './components/EventForm'; -import { EventList } from './components/EventList'; -import { OverlapDialog } from './components/OverlapDialog'; -import { NotificationStack } from './components/NotificationStack'; -import { CalendarLayout } from './components/CalendarLayout'; +import { + EventForm, + EventList, + OverlapDialog, + NotificationStack, + CalendarLayout, +} from './components'; import { useCalendarView } from './hooks/useCalendarView.ts'; import { useEventForm } from './hooks/useEventForm.ts'; diff --git a/src/components/CalendarLayout.tsx b/src/components/calendar/CalendarLayout.tsx similarity index 86% rename from src/components/CalendarLayout.tsx rename to src/components/calendar/CalendarLayout.tsx index e8876bff..7642127e 100644 --- a/src/components/CalendarLayout.tsx +++ b/src/components/calendar/CalendarLayout.tsx @@ -1,8 +1,6 @@ import { Stack, Typography } from '@mui/material'; -import { WeekView } from './WeekView'; -import { MonthView } from './MonthView'; -import { CalendarNavigation } from './CalendarNavigation'; -import { Event } from '../types'; +import { WeekView, MonthView, CalendarNavigation } from './index'; +import { Event } from '../../types'; interface CalendarLayoutProps { view: 'week' | 'month'; diff --git a/src/components/CalendarNavigation.tsx b/src/components/calendar/CalendarNavigation.tsx similarity index 100% rename from src/components/CalendarNavigation.tsx rename to src/components/calendar/CalendarNavigation.tsx diff --git a/src/components/MonthView.tsx b/src/components/calendar/MonthView.tsx similarity index 98% rename from src/components/MonthView.tsx rename to src/components/calendar/MonthView.tsx index 79b548ca..e72a45ff 100644 --- a/src/components/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -10,8 +10,8 @@ import { TableRow, Typography, } from '@mui/material'; -import { Event } from '../types'; -import { formatDate, formatMonth, getEventsForDay, getWeeksAtMonth } from '../utils/dateUtils'; +import { Event } from '../../types'; +import { formatDate, formatMonth, getEventsForDay, getWeeksAtMonth } from '../../utils/dateUtils'; const weekDays = ['일', '월', '화', '수', '목', '금', '토']; diff --git a/src/components/WeekView.tsx b/src/components/calendar/WeekView.tsx similarity index 96% rename from src/components/WeekView.tsx rename to src/components/calendar/WeekView.tsx index 2149c6cc..aad734f3 100644 --- a/src/components/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -10,8 +10,8 @@ import { TableRow, Typography, } from '@mui/material'; -import { Event } from '../types'; -import { formatWeek, getWeekDates } from '../utils/dateUtils'; +import { Event } from '../../types'; +import { formatWeek, getWeekDates } from '../../utils/dateUtils'; const weekDays = ['일', '월', '화', '수', '목', '금', '토']; diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts new file mode 100644 index 00000000..46191e5e --- /dev/null +++ b/src/components/calendar/index.ts @@ -0,0 +1,4 @@ +export { CalendarLayout } from './CalendarLayout'; +export { CalendarNavigation } from './CalendarNavigation'; +export { MonthView } from './MonthView'; +export { WeekView } from './WeekView'; diff --git a/src/components/EventCard.tsx b/src/components/event/EventCard.tsx similarity index 98% rename from src/components/EventCard.tsx rename to src/components/event/EventCard.tsx index 5abcb733..883bfb39 100644 --- a/src/components/EventCard.tsx +++ b/src/components/event/EventCard.tsx @@ -1,6 +1,6 @@ import { Delete, Edit, Notifications } from '@mui/icons-material'; import { Box, IconButton, Stack, Typography } from '@mui/material'; -import { Event } from '../types'; +import { Event } from '../../types'; interface EventCardProps { event: Event; diff --git a/src/components/EventList.tsx b/src/components/event/EventList.tsx similarity index 97% rename from src/components/EventList.tsx rename to src/components/event/EventList.tsx index 4116b634..9ee79a57 100644 --- a/src/components/EventList.tsx +++ b/src/components/event/EventList.tsx @@ -1,5 +1,5 @@ import { Stack, TextField, Typography } from '@mui/material'; -import { Event } from '../types'; +import { Event } from '../../types'; import { EventCard } from './EventCard'; interface EventListProps { diff --git a/src/components/OverlapDialog.tsx b/src/components/event/OverlapDialog.tsx similarity index 96% rename from src/components/OverlapDialog.tsx rename to src/components/event/OverlapDialog.tsx index 9731cd73..a679de33 100644 --- a/src/components/OverlapDialog.tsx +++ b/src/components/event/OverlapDialog.tsx @@ -7,7 +7,7 @@ import { DialogTitle, Typography, } from '@mui/material'; -import { Event } from '../types'; +import { Event } from '../../types'; interface OverlapDialogProps { open: boolean; diff --git a/src/components/event/index.ts b/src/components/event/index.ts new file mode 100644 index 00000000..6718bcbc --- /dev/null +++ b/src/components/event/index.ts @@ -0,0 +1,3 @@ +export { EventCard } from './EventCard'; +export { EventList } from './EventList'; +export { OverlapDialog } from './OverlapDialog'; diff --git a/src/components/EventForm.tsx b/src/components/form/EventForm.tsx similarity index 97% rename from src/components/EventForm.tsx rename to src/components/form/EventForm.tsx index ec93b5df..15678268 100644 --- a/src/components/EventForm.tsx +++ b/src/components/form/EventForm.tsx @@ -10,8 +10,8 @@ import { Tooltip, Typography, } from '@mui/material'; -import { EventForm as EventFormType } from '../types'; -import { getTimeErrorMessage } from '../utils/timeValidation'; +import { EventForm as EventFormType } from '../../types'; +import { getTimeErrorMessage } from '../../utils/timeValidation'; const categories = ['업무', '개인', '가족', '기타']; diff --git a/src/components/form/index.ts b/src/components/form/index.ts new file mode 100644 index 00000000..19d3ca40 --- /dev/null +++ b/src/components/form/index.ts @@ -0,0 +1 @@ +export { EventForm } from './EventForm'; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 00000000..77a286a5 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,11 @@ +// Calendar components +export * from './calendar'; + +// Event components +export * from './event'; + +// Form components +export * from './form'; + +// Notification components +export * from './notification'; diff --git a/src/components/NotificationStack.tsx b/src/components/notification/NotificationStack.tsx similarity index 100% rename from src/components/NotificationStack.tsx rename to src/components/notification/NotificationStack.tsx diff --git a/src/components/notification/index.ts b/src/components/notification/index.ts new file mode 100644 index 00000000..af01ba04 --- /dev/null +++ b/src/components/notification/index.ts @@ -0,0 +1 @@ +export { NotificationStack } from './NotificationStack'; From e38686c960c9e1fe6327f648b845ee72247f4bb4 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 09:23:29 +0900 Subject: [PATCH 31/43] =?UTF-8?q?chore:=20=EB=A6=B0=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.js | 14 ++------------ src/App.tsx | 3 ++- src/components/calendar/CalendarLayout.tsx | 4 +++- src/components/calendar/MonthView.tsx | 2 ++ src/components/calendar/WeekView.tsx | 2 ++ src/components/event/EventCard.tsx | 1 + src/components/event/EventList.tsx | 2 ++ src/components/event/OverlapDialog.tsx | 1 + src/components/form/EventForm.tsx | 3 +++ 9 files changed, 18 insertions(+), 14 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index d811750b..c6519cc0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -78,18 +78,8 @@ export default [ ...reactHooksPlugin.configs.recommended.rules, 'react-hooks/exhaustive-deps': 'off', - // Import rules - 'import/order': [ - 'error', - { - groups: ['builtin', 'external', ['parent', 'sibling'], 'index'], - alphabetize: { - order: 'asc', - caseInsensitive: true, - }, - 'newlines-between': 'always', - }, - ], + // Import rules disabled due to conflict with Prettier + // 'import/order': 'off', // Prettier rules ...prettierConfig.rules, diff --git a/src/App.tsx b/src/App.tsx index ba393136..79e41900 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { Box, Stack } from '@mui/material'; + import { EventForm, EventList, @@ -9,10 +10,10 @@ import { import { useCalendarView } from './hooks/useCalendarView.ts'; import { useEventForm } from './hooks/useEventForm.ts'; +import { useEventManagement } from './hooks/useEventManagement.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; import { useNotifications } from './hooks/useNotifications.ts'; import { useSearch } from './hooks/useSearch.ts'; -import { useEventManagement } from './hooks/useEventManagement.ts'; function App() { const { diff --git a/src/components/calendar/CalendarLayout.tsx b/src/components/calendar/CalendarLayout.tsx index 7642127e..7ad83cd3 100644 --- a/src/components/calendar/CalendarLayout.tsx +++ b/src/components/calendar/CalendarLayout.tsx @@ -1,7 +1,9 @@ import { Stack, Typography } from '@mui/material'; -import { WeekView, MonthView, CalendarNavigation } from './index'; + import { Event } from '../../types'; +import { WeekView, MonthView, CalendarNavigation } from './index'; + interface CalendarLayoutProps { view: 'week' | 'month'; setView: (view: 'week' | 'month') => void; diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index e72a45ff..06776d6f 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -10,7 +10,9 @@ import { TableRow, Typography, } from '@mui/material'; + import { Event } from '../../types'; + import { formatDate, formatMonth, getEventsForDay, getWeeksAtMonth } from '../../utils/dateUtils'; const weekDays = ['일', '월', '화', '수', '목', '금', '토']; diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index aad734f3..792c3ecc 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -10,7 +10,9 @@ import { TableRow, Typography, } from '@mui/material'; + import { Event } from '../../types'; + import { formatWeek, getWeekDates } from '../../utils/dateUtils'; const weekDays = ['일', '월', '화', '수', '목', '금', '토']; diff --git a/src/components/event/EventCard.tsx b/src/components/event/EventCard.tsx index 883bfb39..abb6cbaf 100644 --- a/src/components/event/EventCard.tsx +++ b/src/components/event/EventCard.tsx @@ -1,5 +1,6 @@ import { Delete, Edit, Notifications } from '@mui/icons-material'; import { Box, IconButton, Stack, Typography } from '@mui/material'; + import { Event } from '../../types'; interface EventCardProps { diff --git a/src/components/event/EventList.tsx b/src/components/event/EventList.tsx index 9ee79a57..e9e01d70 100644 --- a/src/components/event/EventList.tsx +++ b/src/components/event/EventList.tsx @@ -1,5 +1,7 @@ import { Stack, TextField, Typography } from '@mui/material'; + import { Event } from '../../types'; + import { EventCard } from './EventCard'; interface EventListProps { diff --git a/src/components/event/OverlapDialog.tsx b/src/components/event/OverlapDialog.tsx index a679de33..dd94e0e3 100644 --- a/src/components/event/OverlapDialog.tsx +++ b/src/components/event/OverlapDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, Typography, } from '@mui/material'; + import { Event } from '../../types'; interface OverlapDialogProps { diff --git a/src/components/form/EventForm.tsx b/src/components/form/EventForm.tsx index 15678268..76115010 100644 --- a/src/components/form/EventForm.tsx +++ b/src/components/form/EventForm.tsx @@ -10,6 +10,9 @@ import { Tooltip, Typography, } from '@mui/material'; + +import React from 'react'; + import { EventForm as EventFormType } from '../../types'; import { getTimeErrorMessage } from '../../utils/timeValidation'; From a76d1a0f5e090a7eda3d3a73624abd8cb2b2c036 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 09:23:39 +0900 Subject: [PATCH 32/43] =?UTF-8?q?chore:=20=EB=A6=B0=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useEventManagement.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useEventManagement.ts b/src/hooks/useEventManagement.ts index 957b722f..6111b6fa 100644 --- a/src/hooks/useEventManagement.ts +++ b/src/hooks/useEventManagement.ts @@ -1,5 +1,6 @@ -import { useState } from 'react'; import { useSnackbar } from 'notistack'; +import { useState } from 'react'; + import { Event, EventForm as EventFormType, RepeatType } from '../types'; import { findOverlappingEvents } from '../utils/eventOverlap'; From 3a437e5117bd7065d7ccfedd8ecd894be94d2448 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 09:37:19 +0900 Subject: [PATCH 33/43] =?UTF-8?q?feat:=20=EC=B6=94=EA=B0=80=ED=95=9C=20?= =?UTF-8?q?=ED=9B=85,=20=ED=8F=BC=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=9B=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__/components/EventForm.spec.tsx | 210 ++++++++++++++++++ .../hooks/useEventManagement.spec.ts | 131 +++++++++++ src/components/form/EventForm.tsx | 3 +- 3 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/components/EventForm.spec.tsx create mode 100644 src/__tests__/hooks/useEventManagement.spec.ts diff --git a/src/__tests__/components/EventForm.spec.tsx b/src/__tests__/components/EventForm.spec.tsx new file mode 100644 index 00000000..ea97e660 --- /dev/null +++ b/src/__tests__/components/EventForm.spec.tsx @@ -0,0 +1,210 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { vi } from 'vitest'; +import { EventForm } from '../../components/form/EventForm'; + +// notistack의 useSnackbar를 모킹 +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ + enqueueSnackbar: mockEnqueueSnackbar, + }), +})); + +describe('EventForm', () => { + const mockProps = { + title: '', + setTitle: vi.fn(), + date: '2024-01-01', + setDate: vi.fn(), + startTime: '09:00', + endTime: '10:00', + description: '', + setDescription: vi.fn(), + location: '', + setLocation: vi.fn(), + category: '업무', + setCategory: vi.fn(), + isRepeating: false, + setIsRepeating: vi.fn(), + notificationTime: 10, + setNotificationTime: vi.fn(), + startTimeError: null, + endTimeError: null, + editingEvent: null, + handleStartTimeChange: vi.fn(), + handleEndTimeChange: vi.fn(), + addOrUpdateEvent: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('일정 추가 모드에서 모든 폼 UI 요소들이 올바르게 렌더링되어야 한다', () => { + render(); + + // 제목이 올바르게 표시되어야 함 + expect(screen.getByRole('heading', { name: '일정 추가' })).toBeInTheDocument(); + + // 필수 입력 필드들이 존재해야 함 + expect(screen.getByLabelText('제목')).toBeInTheDocument(); + expect(screen.getByLabelText('날짜')).toBeInTheDocument(); + expect(screen.getByLabelText('시작 시간')).toBeInTheDocument(); + expect(screen.getByLabelText('종료 시간')).toBeInTheDocument(); + expect(screen.getByLabelText('설명')).toBeInTheDocument(); + expect(screen.getByLabelText('위치')).toBeInTheDocument(); + expect(screen.getByLabelText('카테고리')).toBeInTheDocument(); + expect(screen.getByLabelText('알림 설정')).toBeInTheDocument(); + + // 반복 일정 체크박스가 존재해야 함 + expect(screen.getByLabelText('반복 일정')).toBeInTheDocument(); + + // 제출 버튼이 올바른 텍스트와 함께 존재해야 함 + const submitButton = screen.getByTestId('event-submit-button'); + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toHaveTextContent('일정 추가'); + }); + + it('사용자 입력 시 해당 setter 함수들이 올바르게 호출되어야 한다', () => { + render(); + + // 제목 입력 테스트 + const titleInput = screen.getByLabelText('제목'); + fireEvent.change(titleInput, { target: { value: '새로운 일정' } }); + expect(mockProps.setTitle).toHaveBeenCalledWith('새로운 일정'); + + // 날짜 변경 테스트 + const dateInput = screen.getByLabelText('날짜'); + fireEvent.change(dateInput, { target: { value: '2024-01-15' } }); + expect(mockProps.setDate).toHaveBeenCalledWith('2024-01-15'); + + // 설명 입력 테스트 + const descriptionInput = screen.getByLabelText('설명'); + fireEvent.change(descriptionInput, { target: { value: '일정에 대한 상세 설명' } }); + expect(mockProps.setDescription).toHaveBeenCalledWith('일정에 대한 상세 설명'); + + // 위치 입력 테스트 + const locationInput = screen.getByLabelText('위치'); + fireEvent.change(locationInput, { target: { value: '회사 회의실' } }); + expect(mockProps.setLocation).toHaveBeenCalledWith('회사 회의실'); + + // 반복 일정 체크박스 테스트 + const repeatCheckbox = screen.getByLabelText('반복 일정'); + fireEvent.click(repeatCheckbox); + expect(mockProps.setIsRepeating).toHaveBeenCalledWith(true); + }); + + it('시간 입력 시 해당 핸들러 함수들이 호출되어야 한다', () => { + render(); + + // 시작 시간 변경 테스트 + const startTimeInput = screen.getByLabelText('시작 시간'); + fireEvent.change(startTimeInput, { target: { value: '10:00' } }); + expect(mockProps.handleStartTimeChange).toHaveBeenCalled(); + + // 종료 시간 변경 테스트 + const endTimeInput = screen.getByLabelText('종료 시간'); + fireEvent.change(endTimeInput, { target: { value: '11:00' } }); + expect(mockProps.handleEndTimeChange).toHaveBeenCalled(); + }); + + it('제출 버튼 클릭 시 addOrUpdateEvent 함수가 호출되어야 한다', () => { + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + expect(mockProps.addOrUpdateEvent).toHaveBeenCalledTimes(1); + }); + + it('필수 정보가 누락된 상태에서 제출 시 유효성 검사가 실행되어야 한다', () => { + const propsWithEmptyRequiredFields = { + ...mockProps, + title: '', // 제목 누락 + date: '', // 날짜 누락 + startTime: '', // 시작 시간 누락 + endTime: '', // 종료 시간 누락 + }; + + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + // addOrUpdateEvent 함수가 호출되어야 함 (유효성 검사는 상위 컴포넌트에서 처리) + expect(mockProps.addOrUpdateEvent).toHaveBeenCalledTimes(1); + }); + + it('시간 에러가 있는 상태에서 제출 시 유효성 검사가 실행되어야 한다', () => { + const propsWithTimeErrors = { + ...mockProps, + startTimeError: '시작 시간이 종료 시간보다 늦습니다', + endTimeError: '종료 시간이 시작 시간보다 빠릅니다', + }; + + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + // addOrUpdateEvent 함수가 호출되어야 함 (유효성 검사는 상위 컴포넌트에서 처리) + expect(mockProps.addOrUpdateEvent).toHaveBeenCalledTimes(1); + }); + + it('필수 정보가 누락된 경우 에러 토스트가 표시된다', () => { + // addOrUpdateEvent 함수가 실제로 validateForm 로직을 실행하도록 모킹 + const mockAddOrUpdateEvent = vi.fn().mockImplementation(() => { + // 필수 정보 누락 시 에러 토스트 표시 + mockEnqueueSnackbar('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + }); + + const propsWithMockValidation = { + ...mockProps, + title: '', // 제목 누락 + date: '', // 날짜 누락 + startTime: '', // 시작 시간 누락 + endTime: '', // 종료 시간 누락 + addOrUpdateEvent: mockAddOrUpdateEvent, + }; + + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + // 에러 토스트 메시지가 표시되어야 함 + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + }); + + it('시간 에러가 있는 경우 에러 토스트가 표시된다', () => { + // addOrUpdateEvent 함수가 실제로 validateForm 로직을 실행하도록 모킹 + const mockAddOrUpdateEvent = vi.fn().mockImplementation(() => { + // 시간 에러가 있는 경우 에러 토스트 표시 + mockEnqueueSnackbar('시간 설정을 확인해주세요.', { + variant: 'error', + }); + }); + + const propsWithMockValidation = { + ...mockProps, + startTimeError: '시작 시간이 종료 시간보다 늦습니다', + endTimeError: '종료 시간이 시작 시간보다 빠릅니다', + addOrUpdateEvent: mockAddOrUpdateEvent, + }; + + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + // 에러 토스트 메시지가 표시되어야 함 + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('시간 설정을 확인해주세요.', { + variant: 'error', + }); + }); +}); diff --git a/src/__tests__/hooks/useEventManagement.spec.ts b/src/__tests__/hooks/useEventManagement.spec.ts new file mode 100644 index 00000000..2d3d1729 --- /dev/null +++ b/src/__tests__/hooks/useEventManagement.spec.ts @@ -0,0 +1,131 @@ +import { renderHook, act } from '@testing-library/react'; +import { vi } from 'vitest'; +import { useEventManagement } from '../../hooks/useEventManagement'; + +// notistack의 useSnackbar를 모킹 +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ + enqueueSnackbar: mockEnqueueSnackbar, + }), +})); + +describe('useEventManagement', () => { + const mockSaveEvent = vi.fn(); + const mockResetForm = vi.fn(); + + const defaultFormData = { + title: '테스트 일정', + date: '2024-01-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 위치', + category: '업무', + isRepeating: false, + repeatType: 'none' as const, + repeatInterval: 1, + repeatEndDate: null, + notificationTime: 10, + startTimeError: '', + endTimeError: '', + editingEvent: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('필수 정보가 누락된 경우 에러 토스트가 표시된다', async () => { + const { result } = renderHook(() => + useEventManagement({ + events: [], + saveEvent: mockSaveEvent, + resetForm: mockResetForm, + formData: { + ...defaultFormData, + title: '', // 제목 누락 + date: '', // 날짜 누락 + }, + }) + ); + + await act(async () => { + result.current.addOrUpdateEvent(); + }); + + // 에러 토스트 메시지가 표시되어야 함 + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + + // saveEvent가 호출되지 않았어야 함 + expect(mockSaveEvent).not.toHaveBeenCalled(); + }); + + it('시간 에러가 있는 경우 에러 토스트가 표시된다', async () => { + const { result } = renderHook(() => + useEventManagement({ + events: [], + saveEvent: mockSaveEvent, + resetForm: mockResetForm, + formData: { + ...defaultFormData, + startTimeError: '시작 시간이 종료 시간보다 늦습니다', + endTimeError: '종료 시간이 시작 시간보다 빠릅니다', + }, + }) + ); + + await act(async () => { + result.current.addOrUpdateEvent(); + }); + + // 에러 토스트 메시지가 표시되어야 함 + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('시간 설정을 확인해주세요.', { + variant: 'error', + }); + + // saveEvent가 호출되지 않았어야 함 + expect(mockSaveEvent).not.toHaveBeenCalled(); + }); + + it('모든 필수 정보가 올바르게 입력된 경우 일정이 저장된다', async () => { + const { result } = renderHook(() => + useEventManagement({ + events: [], + saveEvent: mockSaveEvent, + resetForm: mockResetForm, + formData: defaultFormData, + }) + ); + + await act(async () => { + result.current.addOrUpdateEvent(); + }); + + // 에러 토스트가 표시되지 않아야 함 + expect(mockEnqueueSnackbar).not.toHaveBeenCalled(); + + // saveEvent가 호출되어야 함 + expect(mockSaveEvent).toHaveBeenCalledWith({ + id: undefined, + title: '테스트 일정', + date: '2024-01-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 위치', + category: '업무', + repeat: { + type: 'none', + interval: 1, + endDate: undefined, + }, + notificationTime: 10, + }); + + // 폼이 리셋되어야 함 + expect(mockResetForm).toHaveBeenCalled(); + }); +}); diff --git a/src/components/form/EventForm.tsx b/src/components/form/EventForm.tsx index 76115010..ca464448 100644 --- a/src/components/form/EventForm.tsx +++ b/src/components/form/EventForm.tsx @@ -179,12 +179,13 @@ export function EventForm({ - 알림 설정 + 알림 설정 setCategory(e.target.value)} + aria-labelledby="category-label" + aria-label="카테고리" + > + {categories.map((cat) => ( + + {cat} + + ))} + + + + + setIsRepeating(e.target.checked)} /> + } + label="반복 일정" + /> + + + + 알림 설정 + + + + + + ); +} From ebc1e724137d0ae7d3799b16d28726d24f9e8be4 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 09:56:12 +0900 Subject: [PATCH 37/43] test: add unit tests for MonthView and WeekView components, enhance OverlapDialog mock implementation --- .../components/advanced.MonthView.spec.tsx | 81 +++++++++++++++++++ .../advanced.OverlapDialog.spec.tsx | 53 ++++++++++-- .../components/advanced.WeekView.spec.tsx | 76 +++++++++++++++++ src/components/EventForm.tsx | 1 + 4 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/components/advanced.MonthView.spec.tsx create mode 100644 src/__tests__/components/advanced.WeekView.spec.tsx diff --git a/src/__tests__/components/advanced.MonthView.spec.tsx b/src/__tests__/components/advanced.MonthView.spec.tsx new file mode 100644 index 00000000..45fe63b2 --- /dev/null +++ b/src/__tests__/components/advanced.MonthView.spec.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { MonthView } from '../../components/calendar/MonthView'; +import { Event } from '../../types'; + +// dateUtils 함수들을 모킹 +vi.mock('../../utils/dateUtils', () => ({ + formatDate: vi.fn((_date: Date, day: number) => `2024-01-${day.toString().padStart(2, '0')}`), + formatMonth: vi.fn(() => '2024년 1월'), + getEventsForDay: vi.fn((events: Event[], day: number) => + events.filter((event) => event.date === `2024-01-${day.toString().padStart(2, '0')}`) + ), + getWeeksAtMonth: vi.fn(() => [ + [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, null], + ]), +})); + +describe('MonthView', () => { + const mockProps = { + currentDate: new Date('2024-01-15'), + filteredEvents: [ + { + id: '1', + title: '테스트 일정', + date: '2024-01-15', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 위치', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ] as Event[], + notifiedEvents: ['1'], + holidays: { + '2024-01-01': '신정', + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('렌더링', () => { + it('월 뷰가 올바르게 렌더링되어야 한다', () => { + render(); + + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + expect(screen.getByText('2024년 1월')).toBeInTheDocument(); + }); + + it('요일 헤더가 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('일')).toBeInTheDocument(); + expect(screen.getByText('월')).toBeInTheDocument(); + expect(screen.getByText('화')).toBeInTheDocument(); + expect(screen.getByText('수')).toBeInTheDocument(); + expect(screen.getByText('목')).toBeInTheDocument(); + expect(screen.getByText('금')).toBeInTheDocument(); + expect(screen.getByText('토')).toBeInTheDocument(); + }); + + it('일정이 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('테스트 일정')).toBeInTheDocument(); + }); + + it('공휴일이 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('신정')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/components/advanced.OverlapDialog.spec.tsx b/src/__tests__/components/advanced.OverlapDialog.spec.tsx index e61340b1..de79bb6e 100644 --- a/src/__tests__/components/advanced.OverlapDialog.spec.tsx +++ b/src/__tests__/components/advanced.OverlapDialog.spec.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { render, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { vi } from 'vitest'; @@ -7,22 +8,60 @@ import { Event } from '../../types'; // Mock Material-UI components if needed vi.mock('@mui/material', () => ({ - Button: ({ children, onClick, ...props }: any) => ( + Button: ({ + children, + onClick, + ...props + }: { + children: React.ReactNode; + onClick?: () => void; + [key: string]: unknown; + }) => ( ), - Dialog: ({ children, open, onClose, ...props }: any) => + Dialog: ({ + children, + open, + ...props + }: { + children: React.ReactNode; + open: boolean; + [key: string]: unknown; + }) => open ? (
{children}
) : null, - DialogActions: ({ children, ...props }: any) =>
{children}
, - DialogContent: ({ children, ...props }: any) =>
{children}
, - DialogContentText: ({ children, ...props }: any) =>
{children}
, - DialogTitle: ({ children, ...props }: any) =>
{children}
, - Typography: ({ children, ...props }: any) =>
{children}
, + DialogActions: ({ + children, + ...props + }: { + children: React.ReactNode; + [key: string]: unknown; + }) =>
{children}
, + DialogContent: ({ + children, + ...props + }: { + children: React.ReactNode; + [key: string]: unknown; + }) =>
{children}
, + DialogContentText: ({ + children, + ...props + }: { + children: React.ReactNode; + [key: string]: unknown; + }) =>
{children}
, + DialogTitle: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + Typography: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), })); describe('OverlapDialog', () => { diff --git a/src/__tests__/components/advanced.WeekView.spec.tsx b/src/__tests__/components/advanced.WeekView.spec.tsx new file mode 100644 index 00000000..593acc64 --- /dev/null +++ b/src/__tests__/components/advanced.WeekView.spec.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; +import { WeekView } from '../../components/calendar/WeekView'; +import { Event } from '../../types'; + +// dateUtils 함수들을 모킹 +/* eslint-disable @typescript-eslint/no-unused-vars */ +vi.mock('../../utils/dateUtils', () => ({ + formatWeek: vi.fn((_date: Date) => '2024년 1월 3주차'), + getWeekDates: vi.fn((_date: Date) => [ + new Date('2024-01-14'), + new Date('2024-01-15'), + new Date('2024-01-16'), + new Date('2024-01-17'), + new Date('2024-01-18'), + new Date('2024-01-19'), + new Date('2024-01-20'), + ]), +})); + +describe('WeekView', () => { + const mockProps = { + currentDate: new Date('2024-01-15'), + filteredEvents: [ + { + id: '1', + title: '테스트 일정', + date: '2024-01-15', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 위치', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ] as Event[], + notifiedEvents: ['1'], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('렌더링', () => { + it('주 뷰가 올바르게 렌더링되어야 한다', () => { + render(); + + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + expect(screen.getByText('2024년 1월 3주차')).toBeInTheDocument(); + }); + + it('요일 헤더가 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('일')).toBeInTheDocument(); + expect(screen.getByText('월')).toBeInTheDocument(); + expect(screen.getByText('화')).toBeInTheDocument(); + expect(screen.getByText('수')).toBeInTheDocument(); + expect(screen.getByText('목')).toBeInTheDocument(); + expect(screen.getByText('금')).toBeInTheDocument(); + expect(screen.getByText('토')).toBeInTheDocument(); + }); + + it('일정이 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('테스트 일정')).toBeInTheDocument(); + }); + + it('날짜가 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('15')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/EventForm.tsx b/src/components/EventForm.tsx index ec93b5df..d1eeb17b 100644 --- a/src/components/EventForm.tsx +++ b/src/components/EventForm.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Checkbox, FormControl, From aef0379bd0b1193b2737f0ec74107eeafec8b485 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 10:02:55 +0900 Subject: [PATCH 38/43] test: add boundary and date tests for MonthView and WeekView components to ensure stability with various props --- .../components/advanced.MonthView.spec.tsx | 67 ++++++++++++++++++- .../components/advanced.WeekView.spec.tsx | 59 ++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/__tests__/components/advanced.MonthView.spec.tsx b/src/__tests__/components/advanced.MonthView.spec.tsx index 45fe63b2..88f11700 100644 --- a/src/__tests__/components/advanced.MonthView.spec.tsx +++ b/src/__tests__/components/advanced.MonthView.spec.tsx @@ -1,5 +1,4 @@ import { render, screen } from '@testing-library/react'; -import React from 'react'; import { MonthView } from '../../components/calendar/MonthView'; import { Event } from '../../types'; @@ -78,4 +77,70 @@ describe('MonthView', () => { expect(screen.getByText('신정')).toBeInTheDocument(); }); }); + + describe('경계값 테스트', () => { + it('빈 일정 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const emptyProps = { ...mockProps, filteredEvents: [] }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('빈 공휴일 객체로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const noHolidayProps = { ...mockProps, holidays: {} }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('빈 알림 이벤트 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const noNotificationProps = { ...mockProps, notifiedEvents: [] }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('매우 많은 일정이 있어도 렌더링이 정상적으로 되어야 한다', () => { + const manyEvents = Array.from({ length: 50 }, (_, i) => ({ + id: `event-${i}`, + title: `일정 ${i}`, + date: '2024-01-15', + startTime: '09:00', + endTime: '10:00', + description: `설명 ${i}`, + location: `위치 ${i}`, + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + })) as Event[]; + + const manyEventsProps = { ...mockProps, filteredEvents: manyEvents }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + }); + + describe('다양한 날짜 테스트', () => { + it('12월로 렌더링해도 정상 작동해야 한다', () => { + const decemberProps = { ...mockProps, currentDate: new Date('2024-12-15') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('연초(1월) 날짜로 렌더링해도 정상 작동해야 한다', () => { + const januaryProps = { ...mockProps, currentDate: new Date('2024-01-01') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('윤년 2월 날짜로 렌더링해도 정상 작동해야 한다', () => { + const leapYearProps = { ...mockProps, currentDate: new Date('2024-02-29') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/components/advanced.WeekView.spec.tsx b/src/__tests__/components/advanced.WeekView.spec.tsx index 593acc64..c81f81a3 100644 --- a/src/__tests__/components/advanced.WeekView.spec.tsx +++ b/src/__tests__/components/advanced.WeekView.spec.tsx @@ -73,4 +73,63 @@ describe('WeekView', () => { expect(screen.getByText('15')).toBeInTheDocument(); }); }); + + describe('경계값 테스트', () => { + it('빈 일정 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const emptyProps = { ...mockProps, filteredEvents: [] }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + + it('빈 알림 이벤트 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const noNotificationProps = { ...mockProps, notifiedEvents: [] }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + + it('매우 많은 일정이 있어도 렌더링이 정상적으로 되어야 한다', () => { + const manyEvents = Array.from({ length: 50 }, (_, i) => ({ + id: `event-${i}`, + title: `일정 ${i}`, + date: '2024-01-15', + startTime: '09:00', + endTime: '10:00', + description: `설명 ${i}`, + location: `위치 ${i}`, + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + })) as Event[]; + + const manyEventsProps = { ...mockProps, filteredEvents: manyEvents }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + }); + + describe('다양한 날짜 테스트', () => { + it('연말(12월) 날짜로 렌더링해도 정상 작동해야 한다', () => { + const decemberProps = { ...mockProps, currentDate: new Date('2024-12-15') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + + it('연초(1월) 날짜로 렌더링해도 정상 작동해야 한다', () => { + const januaryProps = { ...mockProps, currentDate: new Date('2024-01-01') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + + it('윤년 2월 날짜로 렌더링해도 정상 작동해야 한다', () => { + const leapYearProps = { ...mockProps, currentDate: new Date('2024-02-29') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + }); }); From 452e5e526a5caa4ac7a34f820cfe6a6532004670 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 10:05:06 +0900 Subject: [PATCH 39/43] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/components/advanced.MonthView.spec.tsx | 2 +- src/__tests__/components/advanced.WeekView.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/components/advanced.MonthView.spec.tsx b/src/__tests__/components/advanced.MonthView.spec.tsx index 88f11700..718407a6 100644 --- a/src/__tests__/components/advanced.MonthView.spec.tsx +++ b/src/__tests__/components/advanced.MonthView.spec.tsx @@ -78,7 +78,7 @@ describe('MonthView', () => { }); }); - describe('경계값 테스트', () => { + describe('엣지 케이스 테스트', () => { it('빈 일정 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { const emptyProps = { ...mockProps, filteredEvents: [] }; diff --git a/src/__tests__/components/advanced.WeekView.spec.tsx b/src/__tests__/components/advanced.WeekView.spec.tsx index c81f81a3..932ecd35 100644 --- a/src/__tests__/components/advanced.WeekView.spec.tsx +++ b/src/__tests__/components/advanced.WeekView.spec.tsx @@ -74,7 +74,7 @@ describe('WeekView', () => { }); }); - describe('경계값 테스트', () => { + describe('엣지 케이스 테스트', () => { it('빈 일정 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { const emptyProps = { ...mockProps, filteredEvents: [] }; From 5270dd3c1b34bb79c792631d40a36149758ca101 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 10:08:02 +0900 Subject: [PATCH 40/43] test: update integration tests to use mock event data for consistency in scheduling scenarios --- src/__tests__/medium.integration.spec.tsx | 32 +++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 62289416..de94309a 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -66,7 +66,21 @@ describe('일정 CRUD 및 기본 기능', () => { }); it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { - server.use(...createMockHandlers(events as Event[])()); + const mockEvents: Event[] = [ + { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); render(); @@ -434,7 +448,21 @@ describe('검색 기능', () => { describe('일정 충돌', () => { it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { - server.use(...createMockHandlers(events as Event[])()); + const mockEvents: Event[] = [ + { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); render(); From 43a02e1422e27b12067ac875dc238eb4dbbcea69 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 10:09:11 +0900 Subject: [PATCH 41/43] =?UTF-8?q?chore:=20=EB=A6=B0=ED=8A=B8=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= 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 de94309a..c2e179e7 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -6,7 +6,6 @@ import { SnackbarProvider } from 'notistack'; import { vi } from 'vitest'; import { createMockHandlers } from '../__mocks__/handlersUtils'; -import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; import App from '../App'; import { server } from '../setupTests'; import { Event } from '../types'; From 36617f59a9b499df4f5181e1d94186290afe45bc Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 10:10:24 +0900 Subject: [PATCH 42/43] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/medium.integration.spec.tsx | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index c2e179e7..abfd8bbc 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -85,37 +85,30 @@ describe('일정 CRUD 및 기본 기능', () => { const user = userEvent.setup(); - // 기존 일정이 표시되는지 확인 (일정 목록 내에서만 검색) - const eventList = await screen.findByTestId('event-list'); expect(within(eventList).getByText('기존 회의')).toBeInTheDocument(); // 일정 수정 버튼 클릭 (첫 번째 일정의 Edit 버튼) const editButton = screen.getByRole('button', { name: 'Edit event' }); - await user.click(editButton); // 첫 번째 Edit 버튼 클릭 + await user.click(editButton); - // 제목 수정 const titleInput = screen.getByDisplayValue('기존 회의'); await user.clear(titleInput); await user.type(titleInput, '수정된 회의'); - // 설명 수정 const descriptionInput = screen.getByDisplayValue('기존 팀 미팅'); await user.clear(descriptionInput); await user.type(descriptionInput, '수정된 팀 미팅'); - // 저장 버튼 클릭 (data-testid 사용) const saveButton = screen.getByTestId('event-submit-button'); await user.click(saveButton); - // 수정된 내용이 반영되었는지 확인 (일정 목록 내에서만 검색) const eventEditList = await screen.findByTestId('event-list'); expect(within(eventEditList).getByText('수정된 회의')).toBeInTheDocument(); expect(within(eventEditList).getByText('수정된 팀 미팅')).toBeInTheDocument(); }); it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { - // MSW Mock Server 설정 (삭제용 - 내장된 mock 데이터 사용) const customEvents: Event[] = [ { id: '1', @@ -136,11 +129,9 @@ describe('일정 CRUD 및 기본 기능', () => { const user = userEvent.setup(); - // 기존 일정이 표시되는지 확인 const eventList = await screen.findByTestId('event-list'); expect(within(eventList).getByText('삭제할 이벤트')).toBeInTheDocument(); - // 삭제 버튼 클릭 ( const deleteButton = within(eventList).getByRole('button', { name: /Delete event/i }); await user.click(deleteButton); @@ -471,9 +462,9 @@ describe('일정 충돌', () => { await user.click(addButton); await user.type(screen.getByLabelText('제목'), '충돌하는 일정'); - await user.type(screen.getByLabelText('날짜'), '2025-10-15'); // 기존 일정과 같은 날짜 - await user.type(screen.getByLabelText('시작 시간'), '09:30'); // 기존 일정과 겹치는 시간 - await user.type(screen.getByLabelText('종료 시간'), '10:30'); // 기존 일정과 겹치는 시간 + await user.type(screen.getByLabelText('날짜'), '2025-10-15'); + await user.type(screen.getByLabelText('시작 시간'), '09:30'); + await user.type(screen.getByLabelText('종료 시간'), '10:30'); await user.type(screen.getByLabelText('설명'), '충돌 테스트'); await user.type(screen.getByLabelText('위치'), '회의실 A'); @@ -558,17 +549,13 @@ describe('알림 기능', () => { server.use(...createMockHandlers([newEvent])()); render(); - // 이벤트가 실제로 렌더링될 때까지 기다림 const eventList = await screen.findByTestId('event-list'); - // 이벤트 제목이 실제로 렌더링될 때까지 기다림 const eventTitle = await within(eventList).findByText(newEvent.title); expect(eventTitle).toBeInTheDocument(); - // 시스템 시간을 설정하고 알림 텍스트 확인 vi.setSystemTime('2025-10-01 09:50'); - // 알림 텍스트가 나타날 때까지 기다림 expect(await screen.findByText('10분 전')).toBeInTheDocument(); }); }); From 8f2a3aa2fe389b6cf10bdd4f57d830b966eed014 Mon Sep 17 00:00:00 2001 From: devchangjun Date: Fri, 22 Aug 2025 14:10:35 +0900 Subject: [PATCH 43/43] =?UTF-8?q?fix:=20id=EA=B0=92=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20=EB=9E=9C?= =?UTF-8?q?=EB=8D=A4=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__mocks__/handlersUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index 373504b3..89f90167 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -19,7 +19,7 @@ export const createMockHandlers = (initialEvents: Event[] = []) => { const newEvent = (await request.json()) as Omit; const eventWithId: Event = { ...newEvent, - id: Date.now().toString(), + id: Math.random().toString(36).substring(2, 15), }; mockEvents.push(eventWithId); return HttpResponse.json(