From a6ad6f9bb7530b095565b63d7cfdcbeda0c1a5ea Mon Sep 17 00:00:00 2001 From: YangS1s Date: Wed, 20 Aug 2025 11:06:51 +0900 Subject: [PATCH 01/14] =?UTF-8?q?medium:=20dateUtil.ts=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 25 ++- src/__tests__/unit/easy.dateUtils.spec.ts | 249 ++++++++++++++++++++-- 3 files changed, 242 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index b01b2b4b..cd722bbf 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "framer-motion": "^12.23.0", "msw": "^2.10.3", "notistack": "^3.0.2", + "prettier": "^3.6.2", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093f3ec7..33b53f04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: notistack: specifier: ^3.0.2 version: 3.0.2(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + prettier: + specifier: ^3.6.2 + version: 3.6.2 react: specifier: 19.1.0 version: 19.1.0 @@ -86,7 +89,7 @@ importers: version: 2.32.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0) eslint-plugin-prettier: specifier: ^5.5.1 - version: 5.5.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.30.0))(eslint@9.30.0)(prettier@3.3.3) + version: 5.5.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.30.0))(eslint@9.30.0)(prettier@3.6.2) eslint-plugin-react: specifier: ^7.37.0 version: 7.37.2(eslint@9.30.0) @@ -95,7 +98,7 @@ importers: version: 5.2.0(eslint@9.30.0) eslint-plugin-storybook: specifier: ^9.0.14 - version: 9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3))(typescript@5.6.3) + version: 9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.6.3) eslint-plugin-vitest: specifier: ^0.5.4 version: 0.5.4(@typescript-eslint/eslint-plugin@8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0)(typescript@5.6.3)(vitest@3.2.4) @@ -2562,8 +2565,8 @@ packages: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} - prettier@3.3.3: - resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true @@ -4958,10 +4961,10 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-prettier@5.5.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.30.0))(eslint@9.30.0)(prettier@3.3.3): + eslint-plugin-prettier@5.5.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.30.0))(eslint@9.30.0)(prettier@3.6.2): dependencies: eslint: 9.30.0 - prettier: 3.3.3 + prettier: 3.6.2 prettier-linter-helpers: 1.0.0 synckit: 0.11.8 optionalDependencies: @@ -4994,11 +4997,11 @@ snapshots: string.prototype.matchall: 4.0.11 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3))(typescript@5.6.3): + eslint-plugin-storybook@9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.6.3): dependencies: '@typescript-eslint/utils': 8.35.0(eslint@9.30.0)(typescript@5.6.3) eslint: 9.30.0 - storybook: 9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3) + storybook: 9.0.14(@testing-library/dom@10.4.0)(prettier@3.6.2) transitivePeerDependencies: - supports-color - typescript @@ -5984,7 +5987,7 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.3.3: {} + prettier@3.6.2: {} pretty-format@27.5.1: dependencies: @@ -6331,7 +6334,7 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3): + storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.6.2): dependencies: '@storybook/global': 5.0.0 '@testing-library/jest-dom': 6.6.3 @@ -6345,7 +6348,7 @@ snapshots: semver: 7.6.3 ws: 8.18.0 optionalDependencies: - prettier: 3.3.3 + prettier: 3.6.2 transitivePeerDependencies: - '@testing-library/dom' - bufferutil diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..cd29f25d 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -10,37 +10,242 @@ import { getWeeksAtMonth, isDateInRange, } from '../../utils/dateUtils'; +import { expect } from 'vitest'; describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); - - it('4월은 30일 일수를 반환한다', () => {}); - - it('윤년의 2월에 대해 29일을 반환한다', () => {}); - - it('평년의 2월에 대해 28일을 반환한다', () => {}); - - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); + it('1월은 31일 수를 반환한다', () => { + const result = getDaysInMonth(2025, 1); + + expect(result).toBe(31); + }); + + it('4월은 30일 일수를 반환한다', () => { + const result = getDaysInMonth(2025, 4); + + expect(result).toBe(30); + }); + + it('윤년의 2월에 대해 29일을 반환한다', () => { + const isLeayYear = (year: number) => { + if (year % 400 === 0) return true; // 400년마다 윤년 + if (year % 100 === 0) return false; // 100년마다 평년 + if (year % 4 === 0) return true; // 4년마다 윤년 + return false; + }; + + const leapYears = Array.from({ length: 41 }) + .map((_, i) => i + 2000) + .filter((year) => isLeayYear(year)); + + leapYears.forEach((year) => { + const result = getDaysInMonth(year, 2); + expect(result).toBe(29); + }); + }); + + it('평년의 2월에 대해 28일을 반환한다', () => { + const result = getDaysInMonth(2025, 2); + expect(result).toBe(28); + }); + + it('유효하지 않은 월에 대해 적절히 처리한다', () => { + const testCase = [ + { input: { year: 2025, month: -1 }, expected: 30 }, + { input: { year: 2025, month: 0 }, expected: 31 }, + { input: { year: 2025, month: 14 }, expected: 28 }, + ]; + + testCase.forEach((testCase) => { + const { input, expected } = testCase; + const result = getDaysInMonth(input.year, input.month); + + expect(result).toBe(expected); + }); + }); }); describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const wednesDay = new Date(2025, 7, 20); + + const result = getWeekDates(wednesDay).map( + (date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + ); + + const expected = [ + '2025-08-17', + '2025-08-18', + '2025-08-19', + '2025-08-20', + '2025-08-21', + '2025-08-22', + '2025-08-23', + ]; + + expect(result).toEqual(expected); + }); + + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const monday = new Date(2025, 7, 18); + + const result = getWeekDates(monday).map( + (date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + ); + + const expected = [ + '2025-08-17', + '2025-08-18', + '2025-08-19', + '2025-08-20', + '2025-08-21', + '2025-08-22', + '2025-08-23', + ]; + + expect(result).toEqual(expected); + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const sunday = new Date(2025, 7, 23); + + const result = getWeekDates(sunday).map( + (date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + ); + + const expected = [ + '2025-08-17', + '2025-08-18', + '2025-08-19', + '2025-08-20', + '2025-08-21', + '2025-08-22', + '2025-08-23', + ]; + + expect(result).toEqual(expected); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + const lastDay = new Date(2025, 11, 31); + + const result = getWeekDates(lastDay).map( + (date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + ); + + const expected = [ + '2025-12-28', + '2025-12-29', + '2025-12-30', + '2025-12-31', + '2026-01-01', + '2026-01-02', + '2026-01-03', + ]; + + expect(result).toEqual(expected); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + const startDay = new Date(2026, 0, 1); + + const result = getWeekDates(startDay).map( + (date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + ); + + const expected = [ + '2025-12-28', + '2025-12-29', + '2025-12-30', + '2025-12-31', + '2026-01-01', + '2026-01-02', + '2026-01-03', + ]; + + expect(result).toEqual(expected); + }); + + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + const isLeapYear = (year: number) => { + if (year % 400 === 0) return true; + if (year % 100 === 0) return false; + if (year % 4 === 0) return true; + return false; + }; + + const leapYears = Array.from({ length: 41 }) + .map((_, i) => i + 2020) + .filter((year) => isLeapYear(year)); + + leapYears.forEach((year) => { + const leapDay = new Date(year, 1, 29); + const result = getWeekDates(leapDay).map( + (date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + ); + const leapDayString = `${year}-02-29`; + expect(result).toContain(leapDayString); + }); + }); + + it.each([ + { + name: '1월 31일', + input: new Date(2025, 0, 31), + expected: [ + '2025-01-26', + '2025-01-27', + '2025-01-28', + '2025-01-29', + '2025-01-30', + '2025-01-31', + '2025-02-01', + ], + }, + { + name: '2월 28일 (평년)', + input: new Date(2025, 1, 28), + expected: [ + '2025-02-23', + '2025-02-24', + '2025-02-25', + '2025-02-26', + '2025-02-27', + '2025-02-28', + '2025-03-01', + ], + }, + ])('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', (v) => { + const result = getWeekDates(v.input).map( + (date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + ); + + expect(result).toEqual(v.expected); + }); +}); - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); +describe('getWeeksAtMonth', () => { + it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => { + const currentDay = new Date(2025, 6, 1); - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); + const result = getWeeksAtMonth(currentDay); - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); + 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], + ]; - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); -}); - -describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); + expect(result).toEqual(expected); + }); }); describe('getEventsForDay', () => { From b2ed131884c55353526731d020834191a60ed1b5 Mon Sep 17 00:00:00 2001 From: YangS1s Date: Wed, 20 Aug 2025 15:26:42 +0900 Subject: [PATCH 02/14] =?UTF-8?q?medium:=20easy.dateUtils.spec.ts,easy.eve?= =?UTF-8?q?ntOverlap.spec.ts=20=EC=9E=91=EC=84=B1=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/__fixture__/eventFactory.ts | 28 ++++ src/__tests__/unit/easy.dateUtils.spec.ts | 163 +++++++++++++++---- src/__tests__/unit/easy.eventOverlap.spec.ts | 21 ++- 3 files changed, 178 insertions(+), 34 deletions(-) create mode 100644 src/__tests__/__fixture__/eventFactory.ts diff --git a/src/__tests__/__fixture__/eventFactory.ts b/src/__tests__/__fixture__/eventFactory.ts new file mode 100644 index 00000000..0b5861b3 --- /dev/null +++ b/src/__tests__/__fixture__/eventFactory.ts @@ -0,0 +1,28 @@ +import { Event } from '../../types.ts'; + +export const factoriesEvents = [ + { + id: '1', + title: '기존 회의', + date: '2025-05-01', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2025-05-02', + startTime: '12:00', + endTime: '13:00', + description: '동료와 점심', + location: '식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 5, + }, +] as Event[]; diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index cd29f25d..8d784683 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -11,6 +11,7 @@ import { isDateInRange, } from '../../utils/dateUtils'; import { expect } from 'vitest'; +import { factoriesEvents } from '../__fixture__/eventFactory.ts'; describe('getDaysInMonth', () => { it('1월은 31일 수를 반환한다', () => { @@ -249,73 +250,175 @@ describe('getWeeksAtMonth', () => { }); describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + const events = factoriesEvents; - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); + const result = getEventsForDay(events, 1); - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); + expect(result[0].title).toEqual('기존 회의'); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + const emptyEvents = [] as Event[]; + + const result = getEventsForDay(emptyEvents, 12); + + expect(result).toEqual([]); + }); + + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + const events = factoriesEvents; + + const result = getEventsForDay(events, 0); + + expect(result).toEqual([]); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + const events = factoriesEvents; + + const result = getEventsForDay(events, 32); - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); + expect(result).toEqual([]); + }); }); describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2025, 0, 15))).toBe('2025년 1월 3주'); + }); - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2025, 0, 1))).toBe('2025년 1월 1주'); + }); - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2025, 6, 27))).toBe('2025년 7월 5주'); + }); - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2025, 11, 28))).toBe('2026년 1월 1주'); + expect(formatWeek(new Date(2026, 0, 2))).toBe('2026년 1월 1주'); + }); - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date(2025, 1, 29)); + expect(result).toBe('2025년 2월 4주'); + }); - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2025, 1, 28))).toBe('2025년 2월 4주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + expect(formatMonth(new Date(2025, 6, 10))).toBe('2025년 7월'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + const targetDate = new Date('2025-07-10'); + const startDate = new Date('2025-07-09'); + const endDate = new Date('2025-07-13'); - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); + expect(isDateInRange(targetDate, startDate, endDate)).toBe(true); + }); - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + const targetDate = new Date('2025-07-10'); + const startDate = new Date('2025-07-01'); + const endDate = new Date('2025-07-13'); - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); + expect(isDateInRange(targetDate, startDate, endDate)).toBe(true); + }); - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + const targetDate = new Date('2025-07-10'); + const startDate = new Date('2025-07-09'); + const endDate = new Date('2025-07-31'); - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + expect(isDateInRange(targetDate, startDate, endDate)).toBe(true); + }); + + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + const targetDate = new Date('2025-06-30'); + const startDate = new Date('2025-06-09'); + const endDate = new Date('2025-06-29'); + + expect(isDateInRange(targetDate, startDate, endDate)).toBe(false); + }); + + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + const targetDate = new Date('2025-08-01'); + const startDate = new Date('2025-08-02'); + const endDate = new Date('2025-08-20'); + + expect(isDateInRange(targetDate, startDate, endDate)).toBe(false); + }); + + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + const targetDate = new Date('2025-08-04'); + const startDate = new Date('2025-08-20'); + const endDate = new Date('2025-08-15'); + expect(isDateInRange(targetDate, startDate, endDate)).toBe(false); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + expect(fillZero(5, 2)).toBe('05'); + }); - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + expect(fillZero(10, 2)).toBe('10'); + }); - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + expect(fillZero(3, 3)).toBe('003'); + }); - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + expect(fillZero(100, 3)).toBe('100'); + }); - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + expect(fillZero(0, 2)).toBe('00'); + }); - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + expect(fillZero(1, 5)).toBe('00001'); + }); - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + expect(fillZero(3.14, 5)).toBe('03.14'); + }); - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + expect(fillZero(3)).toBe('03'); + }); - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + expect(fillZero(100, 1)).toBe('100'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + const currentDate = new Date(2025, 6, 1); + expect(formatDate(currentDate)).toEqual('2025-07-01'); + }); - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + expect(formatDate(new Date(2025, 6, 1), 20)).toEqual('2025-07-20'); + }); - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); + }); - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..15c3f5dc 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -5,14 +5,27 @@ import { isOverlapping, parseDateTime, } from '../../utils/eventOverlap'; +import { expect } from 'vitest'; describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + const date = '2025-07-01'; + const time = '14:30'; - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + expect(parseDateTime(date, time)).toBeInstanceOf(Date); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + //Date { NaN } + expect(parseDateTime('2025-09-666', '12:00').getDate()).toBeNaN(); + }); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + expect(parseDateTime('2025-09-666', '299:00').getDate()).toBeNaN(); + }); + + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + expect(parseDateTime('', '12:00').toString()).toBe('Invalid Date'); + }); }); describe('convertEventToDateRange', () => { From 9de8d801a93804420bc0cf47d8a877e01fd993a8 Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 00:01:42 +0900 Subject: [PATCH 03/14] =?UTF-8?q?medium:=20=EA=B8=B0=EB=B3=B8=EA=B3=BC?= =?UTF-8?q?=EC=A0=9C=20=EC=99=84=EB=A3=8C=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20-=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=99=84=EB=A3=8C=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20-=20Hooks=20=20=20=20=20=20=20=20=20=20=20=20=20-=20Unit=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20-=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=20=20=20=20=20=20=20=20-=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20medium=EC=99=84=EB=A3=8C=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20-=20=EC=97=A3=EC=A7=80=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=B3=B4=EC=B6=A9=20=ED=95=84=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 6 +- pnpm-lock.yaml | 444 ++++++++++++++++-- src/__mocks__/response/realEvents.json | 65 +-- src/__tests__/__fixture__/eventFactory.ts | 60 ++- .../hooks/easy.useCalendarView.spec.ts | 93 +++- src/__tests__/hooks/easy.useSearch.spec.ts | 144 +++++- .../hooks/medium.useEventOperations.spec.ts | 149 +++++- .../hooks/medium.useNotifications.spec.ts | 120 ++++- src/__tests__/medium.integration.spec.tsx | 368 ++++++++++++++- src/__tests__/unit/dummies.ts | 93 ++++ src/__tests__/unit/easy.dateUtils.spec.ts | 131 ++---- src/__tests__/unit/easy.eventOverlap.spec.ts | 151 +++++- src/__tests__/unit/easy.eventUtils.spec.ts | 103 +++- src/__tests__/unit/easy.fetchHolidays.spec.ts | 19 +- .../unit/easy.notificationUtils.spec.ts | 81 +++- .../unit/easy.timeValidation.spec.ts | 28 +- 16 files changed, 1784 insertions(+), 271 deletions(-) create mode 100644 src/__tests__/unit/dummies.ts diff --git a/package.json b/package.json index cd722bbf..a3b120bc 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "build": "tsc -b && vite build", "lint:eslint": "eslint . --ext ts,tsx --report-unused-disable-directives", "lint:tsc": "tsc --pretty", - "lint": "pnpm lint:eslint && pnpm lint:tsc" + "lint": "pnpm lint:eslint && pnpm lint:tsc", + "vitest-preview": "vitest-preview" }, "dependencies": { "@emotion/react": "^11.11.4", @@ -27,7 +28,8 @@ "notistack": "^3.0.2", "prettier": "^3.6.2", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "vitest-preview": "^0.0.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33b53f04..7c4888cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 12.23.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) msw: specifier: ^2.10.3 - version: 2.10.3(@types/node@22.8.1)(typescript@5.6.3) + version: 2.10.3(@types/node@18.19.123)(typescript@5.6.3) notistack: specifier: ^3.0.2 version: 3.0.2(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -41,6 +41,9 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + vitest-preview: + specifier: ^0.0.1 + version: 0.0.1 devDependencies: '@testing-library/jest-dom': specifier: ^6.6.3 @@ -65,7 +68,7 @@ importers: version: 8.35.0(eslint@9.30.0)(typescript@5.6.3) '@vitejs/plugin-react-swc': specifier: ^3.5.0 - version: 3.7.1(vite@7.0.2(@types/node@22.8.1)) + version: 3.7.1(vite@7.0.2(@types/node@18.19.123)) '@vitest/coverage-v8': specifier: ^2.0.3 version: 2.1.3(vitest@3.2.4) @@ -113,13 +116,13 @@ importers: version: 5.6.3 vite: specifier: ^7.0.2 - version: 7.0.2(@types/node@22.8.1) + version: 7.0.2(@types/node@18.19.123) vite-plugin-eslint: specifier: ^1.8.1 - version: 1.8.1(eslint@9.30.0)(vite@7.0.2(@types/node@22.8.1)) + version: 1.8.1(eslint@9.30.0)(vite@7.0.2(@types/node@18.19.123)) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.8.1)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3)) + version: 3.2.4(@types/node@18.19.123)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@18.19.123)(typescript@5.6.3)) packages: @@ -293,6 +296,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.15.18': + resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.5': resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} engines: {node: '>=18'} @@ -347,6 +356,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.15.18': + resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.5': resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} engines: {node: '>=18'} @@ -898,9 +913,15 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -916,12 +937,27 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@4.19.6': + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + + '@types/express@4.17.23': + resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@18.19.123': + resolution: {integrity: sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==} + '@types/node@22.8.1': resolution: {integrity: sha512-k6Gi8Yyo8EtrNtkHXutUu2corfDf9su95VYVP10aGYMMROM6SAItZi0w1XszA6RtWTHSVp5OeFof37w0IEqCQg==} @@ -931,6 +967,12 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.1.6': resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} peerDependencies: @@ -944,6 +986,12 @@ packages: '@types/react@19.1.8': resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==} + '@types/send@0.17.5': + resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} + + '@types/serve-static@1.15.8': + resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} + '@types/statuses@2.0.5': resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} @@ -1041,6 +1089,9 @@ packages: peerDependencies: vite: ^4 || ^5 + '@vitest-preview/dev-utils@0.0.1': + resolution: {integrity: sha512-KLr4IvFz73dMao1tCHWgwqNJfHEcGOqHaQ7SHYfumrMvs2BBD4PKMBtePO2AV7+gq4iEPuIJY8INR3Oq5EnTUw==} + '@vitest/coverage-v8@2.1.3': resolution: {integrity: sha512-2OJ3c7UPoFSmBZwqD2VEkUw6A/tzPF0LmW0ZZhhB8PFxuc+9IBG/FaSM+RLEenc7ljzFvGN+G0nGQoZnh7sy2A==} peerDependencies: @@ -1552,11 +1603,136 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild-android-64@0.15.18: + resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + esbuild-android-arm64@0.15.18: + resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + esbuild-darwin-64@0.15.18: + resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + esbuild-darwin-arm64@0.15.18: + resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + esbuild-freebsd-64@0.15.18: + resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + esbuild-freebsd-arm64@0.15.18: + resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + esbuild-linux-32@0.15.18: + resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + esbuild-linux-64@0.15.18: + resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + esbuild-linux-arm64@0.15.18: + resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + esbuild-linux-arm@0.15.18: + resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + esbuild-linux-mips64le@0.15.18: + resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + esbuild-linux-ppc64le@0.15.18: + resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + esbuild-linux-riscv64@0.15.18: + resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + esbuild-linux-s390x@0.15.18: + resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + esbuild-netbsd-64@0.15.18: + resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + esbuild-openbsd-64@0.15.18: + resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: esbuild: '>=0.12 <1' + esbuild-sunos-64@0.15.18: + resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + esbuild-windows-32@0.15.18: + resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + esbuild-windows-64@0.15.18: + resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + esbuild-windows-arm64@0.15.18: + resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + esbuild@0.15.18: + resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.5: resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} engines: {node: '>=18'} @@ -3079,6 +3255,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} @@ -3115,6 +3294,31 @@ packages: eslint: '>=7' vite: '>=2' + vite@3.2.11: + resolution: {integrity: sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.0.2: resolution: {integrity: sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3155,6 +3359,10 @@ packages: yaml: optional: true + vitest-preview@0.0.1: + resolution: {integrity: sha512-rKh+rzW54HYfgYjCU/9n8t0V8rnxYiH67uJGYUKKqW5L87Cl8NESDzNe2BbD6WmNvM4ojQdc0VqLXv6QsDt1Jw==} + hasBin: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3516,6 +3724,9 @@ snapshots: '@esbuild/android-arm64@0.25.5': optional: true + '@esbuild/android-arm@0.15.18': + optional: true + '@esbuild/android-arm@0.25.5': optional: true @@ -3543,6 +3754,9 @@ snapshots: '@esbuild/linux-ia32@0.25.5': optional: true + '@esbuild/linux-loong64@0.15.18': + optional: true + '@esbuild/linux-loong64@0.25.5': optional: true @@ -3651,16 +3865,16 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inquirer/confirm@5.0.1(@types/node@22.8.1)': + '@inquirer/confirm@5.0.1(@types/node@18.19.123)': dependencies: - '@inquirer/core': 10.0.1(@types/node@22.8.1) - '@inquirer/type': 3.0.0(@types/node@22.8.1) - '@types/node': 22.8.1 + '@inquirer/core': 10.0.1(@types/node@18.19.123) + '@inquirer/type': 3.0.0(@types/node@18.19.123) + '@types/node': 18.19.123 - '@inquirer/core@10.0.1(@types/node@22.8.1)': + '@inquirer/core@10.0.1(@types/node@18.19.123)': dependencies: '@inquirer/figures': 1.0.7 - '@inquirer/type': 3.0.0(@types/node@22.8.1) + '@inquirer/type': 3.0.0(@types/node@18.19.123) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -3673,9 +3887,9 @@ snapshots: '@inquirer/figures@1.0.7': {} - '@inquirer/type@3.0.0(@types/node@22.8.1)': + '@inquirer/type@3.0.0(@types/node@18.19.123)': dependencies: - '@types/node': 22.8.1 + '@types/node': 18.19.123 '@isaacs/cliui@8.0.2': dependencies: @@ -3993,10 +4207,19 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.8.1 + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.8.1 + '@types/cookie@0.6.0': {} '@types/deep-eql@4.0.2': {} @@ -4010,10 +4233,32 @@ snapshots: '@types/estree@1.0.8': {} + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 22.8.1 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.5 + + '@types/express@4.17.23': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.8 + + '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/mime@1.3.5': {} + + '@types/node@18.19.123': + dependencies: + undici-types: 5.26.5 + '@types/node@22.8.1': dependencies: undici-types: 6.19.8 @@ -4022,6 +4267,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@19.1.6(@types/react@19.1.8)': dependencies: '@types/react': 19.1.8 @@ -4034,6 +4283,17 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/send@0.17.5': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.8.1 + + '@types/serve-static@1.15.8': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.8.1 + '@types/send': 0.17.5 + '@types/statuses@2.0.5': {} '@types/tough-cookie@4.0.5': {} @@ -4168,13 +4428,17 @@ snapshots: '@typescript-eslint/types': 8.35.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react-swc@3.7.1(vite@7.0.2(@types/node@22.8.1))': + '@vitejs/plugin-react-swc@3.7.1(vite@7.0.2(@types/node@18.19.123))': dependencies: '@swc/core': 1.7.40 - vite: 7.0.2(@types/node@22.8.1) + vite: 7.0.2(@types/node@18.19.123) transitivePeerDependencies: - '@swc/helpers' + '@vitest-preview/dev-utils@0.0.1': + dependencies: + open: 8.4.2 + '@vitest/coverage-v8@2.1.3(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 @@ -4189,7 +4453,7 @@ snapshots: std-env: 3.7.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 3.2.4(@types/node@22.8.1)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3)) + vitest: 3.2.4(@types/node@18.19.123)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@18.19.123)(typescript@5.6.3)) transitivePeerDependencies: - supports-color @@ -4201,14 +4465,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3))(vite@7.0.2(@types/node@22.8.1))': + '@vitest/mocker@3.2.4(msw@2.10.3(@types/node@18.19.123)(typescript@5.6.3))(vite@7.0.2(@types/node@18.19.123))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - msw: 2.10.3(@types/node@22.8.1)(typescript@5.6.3) - vite: 7.0.2(@types/node@22.8.1) + msw: 2.10.3(@types/node@18.19.123)(typescript@5.6.3) + vite: 7.0.2(@types/node@18.19.123) '@vitest/pretty-format@3.2.4': dependencies: @@ -4239,7 +4503,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@22.8.1)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3)) + vitest: 3.2.4(@types/node@18.19.123)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@18.19.123)(typescript@5.6.3)) '@vitest/utils@3.2.4': dependencies: @@ -4864,6 +5128,54 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 + esbuild-android-64@0.15.18: + optional: true + + esbuild-android-arm64@0.15.18: + optional: true + + esbuild-darwin-64@0.15.18: + optional: true + + esbuild-darwin-arm64@0.15.18: + optional: true + + esbuild-freebsd-64@0.15.18: + optional: true + + esbuild-freebsd-arm64@0.15.18: + optional: true + + esbuild-linux-32@0.15.18: + optional: true + + esbuild-linux-64@0.15.18: + optional: true + + esbuild-linux-arm64@0.15.18: + optional: true + + esbuild-linux-arm@0.15.18: + optional: true + + esbuild-linux-mips64le@0.15.18: + optional: true + + esbuild-linux-ppc64le@0.15.18: + optional: true + + esbuild-linux-riscv64@0.15.18: + optional: true + + esbuild-linux-s390x@0.15.18: + optional: true + + esbuild-netbsd-64@0.15.18: + optional: true + + esbuild-openbsd-64@0.15.18: + optional: true + esbuild-register@3.6.0(esbuild@0.25.5): dependencies: debug: 4.4.1 @@ -4871,6 +5183,43 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild-sunos-64@0.15.18: + optional: true + + esbuild-windows-32@0.15.18: + optional: true + + esbuild-windows-64@0.15.18: + optional: true + + esbuild-windows-arm64@0.15.18: + optional: true + + esbuild@0.15.18: + optionalDependencies: + '@esbuild/android-arm': 0.15.18 + '@esbuild/linux-loong64': 0.15.18 + esbuild-android-64: 0.15.18 + esbuild-android-arm64: 0.15.18 + esbuild-darwin-64: 0.15.18 + esbuild-darwin-arm64: 0.15.18 + esbuild-freebsd-64: 0.15.18 + esbuild-freebsd-arm64: 0.15.18 + esbuild-linux-32: 0.15.18 + esbuild-linux-64: 0.15.18 + esbuild-linux-arm: 0.15.18 + esbuild-linux-arm64: 0.15.18 + esbuild-linux-mips64le: 0.15.18 + esbuild-linux-ppc64le: 0.15.18 + esbuild-linux-riscv64: 0.15.18 + esbuild-linux-s390x: 0.15.18 + esbuild-netbsd-64: 0.15.18 + esbuild-openbsd-64: 0.15.18 + esbuild-sunos-64: 0.15.18 + esbuild-windows-32: 0.15.18 + esbuild-windows-64: 0.15.18 + esbuild-windows-arm64: 0.15.18 + esbuild@0.25.5: optionalDependencies: '@esbuild/aix-ppc64': 0.25.5 @@ -5012,7 +5361,7 @@ snapshots: eslint: 9.30.0 optionalDependencies: '@typescript-eslint/eslint-plugin': 8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0)(typescript@5.6.3) - vitest: 3.2.4(@types/node@22.8.1)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3)) + vitest: 3.2.4(@types/node@18.19.123)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@18.19.123)(typescript@5.6.3)) transitivePeerDependencies: - supports-color - typescript @@ -5798,12 +6147,12 @@ snapshots: ms@2.1.3: {} - msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3): + msw@2.10.3(@types/node@18.19.123)(typescript@5.6.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 5.0.1(@types/node@22.8.1) + '@inquirer/confirm': 5.0.1(@types/node@18.19.123) '@mswjs/interceptors': 0.39.2 '@open-draft/deferred-promise': 2.2.0 '@open-draft/until': 2.1.0 @@ -6630,6 +6979,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@6.19.8: {} universalify@0.2.0: {} @@ -6649,13 +7000,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.8.1): + vite-node@3.2.4(@types/node@18.19.123): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.2(@types/node@22.8.1) + vite: 7.0.2(@types/node@18.19.123) transitivePeerDependencies: - '@types/node' - jiti @@ -6670,15 +7021,25 @@ snapshots: - tsx - yaml - vite-plugin-eslint@1.8.1(eslint@9.30.0)(vite@7.0.2(@types/node@22.8.1)): + vite-plugin-eslint@1.8.1(eslint@9.30.0)(vite@7.0.2(@types/node@18.19.123)): dependencies: '@rollup/pluginutils': 4.2.1 '@types/eslint': 8.56.12 eslint: 9.30.0 rollup: 2.79.2 - vite: 7.0.2(@types/node@22.8.1) + vite: 7.0.2(@types/node@18.19.123) - vite@7.0.2(@types/node@22.8.1): + vite@3.2.11(@types/node@18.19.123): + dependencies: + esbuild: 0.15.18 + postcss: 8.5.6 + resolve: 1.22.8 + rollup: 2.79.2 + optionalDependencies: + '@types/node': 18.19.123 + fsevents: 2.3.3 + + vite@7.0.2(@types/node@18.19.123): dependencies: esbuild: 0.25.5 fdir: 6.4.6(picomatch@4.0.2) @@ -6687,14 +7048,29 @@ snapshots: rollup: 4.44.1 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 22.8.1 + '@types/node': 18.19.123 fsevents: 2.3.3 - vitest@3.2.4(@types/node@22.8.1)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3)): + vitest-preview@0.0.1: + dependencies: + '@types/express': 4.17.23 + '@types/node': 18.19.123 + '@vitest-preview/dev-utils': 0.0.1 + express: 4.21.1 + vite: 3.2.11(@types/node@18.19.123) + transitivePeerDependencies: + - less + - sass + - stylus + - sugarss + - supports-color + - terser + + vitest@3.2.4(@types/node@18.19.123)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@18.19.123)(typescript@5.6.3)): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3))(vite@7.0.2(@types/node@22.8.1)) + '@vitest/mocker': 3.2.4(msw@2.10.3(@types/node@18.19.123)(typescript@5.6.3))(vite@7.0.2(@types/node@18.19.123)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -6712,11 +7088,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.2(@types/node@22.8.1) - vite-node: 3.2.4(@types/node@22.8.1) + vite: 7.0.2(@types/node@18.19.123) + vite-node: 3.2.4(@types/node@18.19.123) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.8.1 + '@types/node': 18.19.123 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: diff --git a/src/__mocks__/response/realEvents.json b/src/__mocks__/response/realEvents.json index 5ab618a0..067e82b1 100644 --- a/src/__mocks__/response/realEvents.json +++ b/src/__mocks__/response/realEvents.json @@ -1,64 +1 @@ -{ - "events": [ - { - "id": "2b7545a6-ebee-426c-b906-2329bc8d62bd", - "title": "팀 회의", - "date": "2025-08-20", - "startTime": "10:00", - "endTime": "11:00", - "description": "주간 팀 미팅", - "location": "회의실 A", - "category": "업무", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - }, - { - "id": "09702fb3-a478-40b3-905e-9ab3c8849dcd", - "title": "점심 약속", - "date": "2025-08-21", - "startTime": "12:30", - "endTime": "13:30", - "description": "동료와 점심 식사", - "location": "회사 근처 식당", - "category": "개인", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - }, - { - "id": "da3ca408-836a-4d98-b67a-ca389d07552b", - "title": "프로젝트 마감", - "date": "2025-08-25", - "startTime": "09:00", - "endTime": "18:00", - "description": "분기별 프로젝트 마감", - "location": "사무실", - "category": "업무", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - }, - { - "id": "dac62941-69e5-4ec0-98cc-24c2a79a7f81", - "title": "생일 파티", - "date": "2025-08-28", - "startTime": "19:00", - "endTime": "22:00", - "description": "친구 생일 축하", - "location": "친구 집", - "category": "개인", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - }, - { - "id": "80d85368-b4a4-47b3-b959-25171d49371f", - "title": "운동", - "date": "2025-08-22", - "startTime": "18:00", - "endTime": "19:00", - "description": "주간 운동", - "location": "헬스장", - "category": "개인", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - } - ] -} +{"events":[{"id":"2b7545a6-ebee-426c-b906-2329bc8d62bd","title":"팀 회의","date":"2025-08-20","startTime":"10:00","endTime":"11:00","description":"주간 팀 미팅","location":"회의실 A","category":"업무","repeat":{"type":"none","interval":0},"notificationTime":1},{"id":"da3ca408-836a-4d98-b67a-ca389d07552b","title":"프로젝트 마감","date":"2025-08-25","startTime":"09:00","endTime":"18:00","description":"분기별 프로젝트 마감","location":"사무실","category":"업무","repeat":{"type":"none","interval":0},"notificationTime":1},{"id":"dac62941-69e5-4ec0-98cc-24c2a79a7f81","title":"생일 파티","date":"2025-08-28","startTime":"19:00","endTime":"22:00","description":"친구 생일 축하","location":"친구 집","category":"개인","repeat":{"type":"none","interval":0},"notificationTime":1},{"id":"80d85368-b4a4-47b3-b959-25171d49371f","title":"운동","date":"2025-08-22","startTime":"18:00","endTime":"19:00","description":"주간 운동","location":"헬스장","category":"개인","repeat":{"type":"none","interval":0},"notificationTime":1},{"id":"b49cba92-a1a2-4e3f-a939-da8f98d6c691","title":"나의 일정2","date":"2025-08-27","startTime":"00:00","endTime":"13:20","description":"그냥","location":"우리집","category":"개인","repeat":{"type":"none","interval":1},"notificationTime":10}]} \ No newline at end of file diff --git a/src/__tests__/__fixture__/eventFactory.ts b/src/__tests__/__fixture__/eventFactory.ts index 0b5861b3..cd9f7c0e 100644 --- a/src/__tests__/__fixture__/eventFactory.ts +++ b/src/__tests__/__fixture__/eventFactory.ts @@ -1,28 +1,60 @@ import { Event } from '../../types.ts'; +export const createEvent = ( + overrides: Partial<{ + title: string; + date: string; + startTime: string; + endTime: string; + id?: string; + notificationTime?: number; + description?: string; + }> +): Event => { + return { + id: '', + title: '기본 이벤트', + date: '2025-05-01', + startTime: '09:00', + endTime: '10:00', + description: '기본 설명', + location: '기본 위치', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + ...overrides, + }; +}; export const factoriesEvents = [ - { + createEvent({ id: '1', title: '기존 회의', date: '2025-05-01', startTime: '09:00', endTime: '10:00', - description: '기존 팀 미팅', - location: '회의실 B', - category: '업무', - repeat: { type: 'none', interval: 0 }, - notificationTime: 10, - }, - { + }), + createEvent({ id: '2', title: '점심 약속', date: '2025-05-02', startTime: '12:00', endTime: '13:00', - description: '동료와 점심', - location: '식당', - category: '개인', - repeat: { type: 'none', interval: 0 }, - notificationTime: 5, - }, + }), +] as Event[]; + +export const overlapingEvents = [ + createEvent({ + id: '1', + title: '기존 회의', + date: '2025-05-02', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + id: '2', + title: '점심 약속', + date: '2025-05-02', + startTime: '09:00', + endTime: '10:00', + }), ] as Event[]; diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..fb14765a 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -1,24 +1,97 @@ import { act, renderHook } from '@testing-library/react'; import { useCalendarView } from '../../hooks/useCalendarView.ts'; -import { assertDate } from '../utils.ts'; +// import { assertDate } from '../utils.ts'; describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.view).toBe('month'); + }); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.currentDate).toEqual(new Date('2025-10-01')); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); + 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': '한글날', + }); + }); }); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); +it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + act(() => { + result.current.setView('week'); + }); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + 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'); + }); + expect(result.current.currentDate).toEqual(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'); + }); + expect(result.current.currentDate).toEqual(new Date('2025-09-24')); +}); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); +it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); + act(() => { + result.current.setView('month'); + }); + act(() => { + result.current.navigate('next'); + }); + expect(result.current.currentDate).toEqual(new Date('2025-11-01')); +}); + +it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('month'); + }); + act(() => { + result.current.navigate('prev'); + }); + expect(result.current.currentDate).toEqual(new Date('2025-09-01')); +}); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); +it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-03-01')); + }); + + expect(result.current.holidays).toEqual({ + '2025-03-01': '삼일절', + }); +}); diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 80f57fa3..411259ac 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -1,14 +1,146 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; -import { Event } from '../../types.ts'; +import { expect } from 'vitest'; +import { createEvent } from '../__fixture__/eventFactory.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const caseEvent = [ + createEvent({ + id: '1', + title: 'event1', + date: '2025-05-01', + }), + createEvent({ + id: '2', + title: 'event2', + date: '2025-05-02', + }), + ]; + const { result } = renderHook(() => useSearch(caseEvent, new Date('2025-05-01'), 'month')); + act(() => { + result.current.setSearchTerm(''); + }); -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); + expect(result.current.filteredEvents).toEqual(caseEvent); +}); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); +it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const targetCase = [ + createEvent({ + id: '1', + title: '검색어', + date: '2025-05-01', + }), + ]; + const caseEvent = [ + ...targetCase, + createEvent({ + id: '2', + title: 'event2', + date: '2025-05-02', + }), + ]; + const { result } = renderHook(() => useSearch(caseEvent, new Date('2025-05-01'), 'month')); + act(() => { + result.current.setSearchTerm('검색어'); + }); + expect(result.current.filteredEvents).toEqual(targetCase); +}); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); +it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const targetCase = [ + createEvent({ + id: '1', + title: '검색어', + date: '2025-05-01', + description: '설명을 맞추셈', + }), + ]; + const caseEvent = [ + ...targetCase, + createEvent({ + id: '2', + title: 'event2', + date: '2025-05-02', + description: 'GOOD', + }), + ]; + const { result } = renderHook(() => useSearch(caseEvent, new Date('2025-05-01'), 'month')); + act(() => { + result.current.setSearchTerm('설명'); + }); + expect(result.current.filteredEvents).toEqual(targetCase); +}); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); +it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const targetCase = [ + createEvent({ + id: '1', + title: '검색어', + date: '2025-05-01', + description: '설명을 맞추셈', + }), + createEvent({ + id: '2', + title: 'event2', + date: '2025-05-02', + description: 'GOOD', + }), + createEvent({ + id: '3', + title: 'event3', + date: '2025-05-03', + description: 'HIROSHIMA', + }), + ]; + const caseEvent = [ + ...targetCase, + createEvent({ + id: '4', + title: 'event4', + date: '2025-06-04', + description: 'Not', + }), + createEvent({ + id: '5', + title: 'event5', + date: '2025-06-05', + description: 'Birthday', + }), + ]; + const { result } = renderHook(() => useSearch(caseEvent, new Date('2025-05-01'), 'month')); + + expect(result.current.filteredEvents).toEqual(targetCase); +}); + +it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const updatedCase = [ + createEvent({ + id: '1', + title: '검색어', + date: '2025-05-01', + description: '점심', + }), + ]; + const originCase = [ + createEvent({ + id: '2', + title: 'event2', + date: '2025-05-02', + description: 'GOOD 회의', + }), + ]; + const caseEvent = [...updatedCase, ...originCase]; + const { result } = renderHook(() => useSearch(caseEvent, new Date('2025-05-01'), 'month')); + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.filteredEvents).toEqual(originCase); + act(() => { + result.current.setSearchTerm('점심'); + }); + + expect(result.current.filteredEvents).toEqual(updatedCase); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..5e5c15fb 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { @@ -8,7 +8,9 @@ import { } from '../../__mocks__/handlersUtils.ts'; import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; -import { Event } from '../../types.ts'; +import { EventForm } from '../../types.ts'; +import { createEvent } from '../__fixture__/eventFactory.ts'; +import { expect } from 'vitest'; const enqueueSnackbarFn = vi.fn(); @@ -22,16 +24,145 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + // 초기 데이터 설정? + setupMockHandlerCreation([createEvent({ id: '1', date: '2025-01-01', title: 'event 1' })]); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + const { result } = renderHook(() => useEventOperations(false, enqueueSnackbarFn)); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); +}); + +it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + setupMockHandlerCreation([]); + + const { result } = renderHook(() => useEventOperations(false, enqueueSnackbarFn)); + await waitFor(() => { + expect(result.current.events).toHaveLength(0); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); + + const addEvent: EventForm = { + date: '2025-07-01', + startTime: '12:00', + endTime: '13:00', + title: '점심', + description: '점심시간', + location: '회사', + category: '휴식', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + await act(async () => { + await result.current.saveEvent(addEvent); + }); +}); + +it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + setupMockHandlerUpdating(); + const { result } = renderHook(() => useEventOperations(true, enqueueSnackbarFn)); + await waitFor(() => { + expect(result.current.events).toHaveLength(2); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); + + await act(async () => { + await result.current.saveEvent({ + ...result.current.events[0], + title: 'edit 회의12', + endTime: '13:00', + }); + }); + + expect(result.current.events).toEqual([ + { ...result.current.events[0], title: 'edit 회의12', endTime: '13:00' }, + + result.current.events[1], + ]); +}); + +it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + setupMockHandlerDeletion(); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + const { result } = renderHook(() => useEventOperations(false, enqueueSnackbarFn)); + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + await act(async () => { + await result.current.deleteEvent('1'); + }); + + expect(result.current.events).toEqual([]); +}); + +it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + // 일부러 에러내기 + server.use( + http.get('/api/events', () => { + return HttpResponse.error(); + }) + ); + + renderHook(() => useEventOperations(false, enqueueSnackbarFn)); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); + }); +}); + +it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + server.use( + http.put('/api/events/:id', () => { + return HttpResponse.error(); + }) + ); + const { result } = renderHook(() => useEventOperations(true, enqueueSnackbarFn)); + const editEvent = { + id: '1', + title: '회의2', + date: '2025-10-15', + startTime: '11:00', + endTime: '12:00', + description: '기존 팀 미팅 2', + location: '회의실 C', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + } as EventForm; + + await act(async () => { + await result.current.saveEvent({ + ...editEvent, + }); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); + }); +}); + +it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + server.use( + http.delete('/api/events/:id', () => { + return HttpResponse.error(); + }) + ); + + const { result } = renderHook(() => useEventOperations(false, enqueueSnackbarFn)); + await act(async () => { + await result.current.deleteEvent('1'); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 삭제 실패', { variant: 'error' }); + }); +}); diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts index 7f585ea8..9a954f2c 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -2,13 +2,121 @@ import { act, renderHook } from '@testing-library/react'; import { useNotifications } from '../../hooks/useNotifications.ts'; import { Event } from '../../types.ts'; -import { formatDate } from '../../utils/dateUtils.ts'; -import { parseHM } from '../utils.ts'; +// import { formatDate } from '../../utils/dateUtils.ts'; +// import { parseHM } from '../utils.ts'; +import { createEvent } from '../__fixture__/eventFactory.ts'; +import { expect } from 'vitest'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +describe('useNotifications', () => { + it('초기 상태에서는 알림이 없어야 한다', () => { + const events: Event[] = [ + createEvent({ + id: '1', + date: '2025-10-01', + title: 'event 1', + startTime: '09:00', + endTime: '10:00', + }), + ]; + const { result } = renderHook(() => useNotifications(events)); -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); + expect(result.current.notifications).toEqual([]); + }); -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); + it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + const events: Event[] = [ + createEvent({ + id: '1', + date: '2025-10-01', + title: 'event 1', + startTime: '00:10', + notificationTime: 60, + }), + ]; -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); + const { result } = renderHook(() => useNotifications(events)); + + // 1초뒤 생성 + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.notifications).toHaveLength(1); + + // 2차: 30분 경과 후에도 기존 알림 유지 + act(() => { + vi.advanceTimersByTime(30 * 60 * 1000); + }); + expect(result.current.notifications).toHaveLength(1); // 여전히 1개 + + expect(result.current.notifiedEvents).toEqual(['1']); + }); + + it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const events: Event[] = [ + createEvent({ + id: '1', + date: '2025-10-01', + title: 'event 1', + startTime: '01:00', + notificationTime: 60, + }), + createEvent({ + id: '2', + date: '2025-10-01', + title: 'event 2', + startTime: '01:00', + notificationTime: 60, + }), + ]; + const { result } = renderHook(() => useNotifications(events)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + act(() => { + result.current.removeNotification(1); + }); + + expect(result.current.notifications).toEqual([ + { + id: '1', + message: '60분 후 event 1 일정이 시작됩니다.', + }, + ]); + expect(result.current.notifiedEvents).toEqual(['1', '2']); + }); + + it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + vi.setSystemTime(new Date('2025-10-01T00:59:00')); // 1분 전 + const events: Event[] = [ + createEvent({ + id: '1', + date: '2025-10-01', + title: 'event 1', + startTime: '01:00', + notificationTime: 60, + }), + ]; + + const { result } = renderHook(() => useNotifications(events)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + // 발생 이벤트 확인 + expect(result.current.notifiedEvents).toEqual(['1']); + + // 두번째 시도 + act(() => { + vi.advanceTimersByTime(2000); + }); + // 다시 확인 + expect(result.current.notifiedEvents).toEqual(['1']); + // 세번째 시도 + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current.notifiedEvents).toEqual(['1']); + }); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 0b559b44..b6b6eb28 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,11 +1,10 @@ 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 } 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 { setupMockHandlerCreation, setupMockHandlerDeletion, @@ -14,6 +13,8 @@ import { import App from '../App'; import { server } from '../setupTests'; import { Event } from '../types'; +import { debug } from 'vitest-preview'; +import { expect } from 'vitest'; const theme = createTheme(); @@ -59,37 +60,374 @@ const saveSchedule = async ( describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + + setupMockHandlerCreation(); + + const { user } = setup(); + const newEventData = { + title: '뉴 이벤트', + date: '2025-10-01', + startTime: '00:00', + endTime: '11:00', + location: '회의실 A', + description: '중요한 회의', + category: '업무', + }; + await saveSchedule(user, newEventData); + // 일정 추가 알람 확인 + expect(await screen.findByText('일정이 추가되었습니다.')).toBeInTheDocument(); + + // event-list 안에 추가된 요소를 찾자 + const targetList = within(await screen.getByTestId('event-list')); + expect(targetList.getByText('뉴 이벤트')).toBeInTheDocument(); + }); + + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + setupMockHandlerUpdating(); + + const { user } = setup(); + + // 첫번째 이벤트 리스트의 수정 버튼을 클릭 + const editButton = await screen.findAllByRole('button', { name: 'Edit event' }); + + await user.click(editButton[0]); + // 수정 폼 버튼이 일정 수정으로 변경됨을 확인 검증 + expect(screen.getByRole('button', { name: '일정 수정' })).toBeInTheDocument(); + + // 수정할 제목 내용 확인 + expect(screen.getByDisplayValue('기존 회의')).toBeInTheDocument(); + // 수정 하기 + await user.clear(screen.getByLabelText('제목')); + await user.type(screen.getByLabelText('제목'), '수정된 회의'); + await user.click(screen.getByRole('button', { name: '일정 수정' })); + + // event-list 안에 추가된 요소를 찾자 + const targetList = within(await screen.getByTestId('event-list')); + + expect(targetList.getByText('수정된 회의')).toBeInTheDocument(); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + setupMockHandlerDeletion(); + const { user } = setup(); + const deleteButton = await screen.findAllByRole('button', { name: 'Delete event' }); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + await user.click(deleteButton[0]); + expect(await screen.findByText('일정이 삭제되었습니다.')).toBeInTheDocument(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + const { user } = setup(); + const selectElement = screen.getByLabelText('뷰 타입 선택'); + // 순서대로 div -> ul -> li 이렇게 + await user.click(selectElement); + await user.click(within(selectElement).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: `week-option` })); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + const targetList = within(await screen.getByTestId('event-list')); + expect(targetList.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ + events: [ + { + id: '1', + title: '기존 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ], + }); + }) + ); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + const { user } = setup(); + const selectElement = screen.getByLabelText('뷰 타입 선택'); + // 순서대로 div -> ul -> li 이렇게 + await user.click(selectElement); + await user.click(within(selectElement).getByRole('combobox')); + debug(); + await user.click(screen.getByRole('option', { name: `week-option` })); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + const targetList = within(await screen.getByTestId('event-list')); + debug(); + expect(targetList.getByText('기존 회의')).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: [] }); + }) + ); + const { user } = setup(); + const selectElement = screen.getByLabelText('뷰 타입 선택'); + // 순서대로 div -> ul -> li 이렇게 + await user.click(selectElement); + await user.click(within(selectElement).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: `month-option` })); + + const targetList = within(await screen.getByTestId('event-list')); + expect(targetList.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ + events: [ + { + id: '1', + title: '기존 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ], + }); + }) + ); + + const { user } = setup(); + const selectElement = screen.getByLabelText('뷰 타입 선택'); + // 순서대로 div -> ul -> li 이렇게 + await user.click(selectElement); + await user.click(within(selectElement).getByRole('combobox')); + + await user.click(screen.getByRole('option', { name: `month-option` })); + + const targetList = within(await screen.getByTestId('event-list')); + expect(targetList.getByText('기존 회의')).toBeInTheDocument(); + }); + + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + const { user } = setup(); + const prevButton = screen.getByRole('button', { name: 'Previous' }); + // 최대 누른 횟수 + let maxAttempts = 12; + + for (let i = 0; i < maxAttempts; i++) { + // 신정이 나오면 멈춤 + if (screen.queryByText('신정')) { + break; + } + // 버튼을 최대한 눌러봐~ + await user.click(prevButton); + } + + debug(); + expect(screen.getByText('신정')).toBeInTheDocument(); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ + events: [], + }); + }) + ); + const { user } = setup(); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + // 인풋 부터 찾자 + const inputElement = screen.getByPlaceholderText('검색어를 입력하세요'); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + await user.click(inputElement); + await user.type(inputElement, '회의'); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ + events: [ + { + id: '1', + title: '팀 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ], + }); + }) + ); + const { user } = setup(); + + // 인풋 부터 찾자 + const inputElement = screen.getByPlaceholderText('검색어를 입력하세요'); + + await user.click(inputElement); + await user.type(inputElement, '팀 회의'); + + const targetList = within(await screen.getByTestId('event-list')); + expect(targetList.getByText('팀 회의')).toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ + events: [ + { + id: '1', + title: '팀 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '팀 회의2', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ], + }); + }) + ); + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + + await user.click(searchInput); + + await user.type(searchInput, '팀 회의'); + + await user.clear(searchInput); + + const targetList = within(await screen.getByTestId('event-list')); + + expect(targetList.getByText('팀 회의')).toBeInTheDocument(); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '팀 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '팀 회의2', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + const newEventData = { + title: '뉴 이벤트', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + }; + await saveSchedule(user, newEventData); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); + + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + setupMockHandlerUpdating(); + const { user } = setup(); + + // 첫번째 이벤트 리스트의 수정 버튼을 클릭 + const editButton = await screen.findAllByRole('button', { name: 'Edit event' }); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + await user.click(editButton[0]); + // 수정 폼 버튼이 일정 수정으로 변경됨을 확인 검증 + expect(screen.getByRole('button', { name: '일정 수정' })).toBeInTheDocument(); + + // 수정할 제목 내용 확인 + expect(screen.getByDisplayValue('기존 회의')).toBeInTheDocument(); + // 수정 하기 + await user.clear(screen.getByLabelText('제목')); + await user.type(screen.getByLabelText('제목'), '수정된 회의'); + await user.clear(screen.getByLabelText('시작 시간')); + await user.type(screen.getByLabelText('시작 시간'), '11:00'); + await user.clear(screen.getByLabelText('종료 시간')); + await user.type(screen.getByLabelText('종료 시간'), '12:00'); + await user.click(screen.getByRole('button', { name: '일정 수정' })); + + // event-list 안에 추가된 요소를 찾자 + debug(); + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-01', + startTime: '00:10', + endTime: '01:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + setup(); + + await screen.findByText('일정 로딩 완료!'); + + expect(await screen.findByText(/일정이 시작됩니다/)).toBeInTheDocument(); +}); diff --git a/src/__tests__/unit/dummies.ts b/src/__tests__/unit/dummies.ts new file mode 100644 index 00000000..42cd4dda --- /dev/null +++ b/src/__tests__/unit/dummies.ts @@ -0,0 +1,93 @@ +import { createEvent } from '../__fixture__/eventFactory.ts'; + +export const caseEvent1 = [ + createEvent({ + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 2', + date: '2025-07-02', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 3', + date: '2025-07-03', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 4', + date: '2025-07-04', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 5', + date: '2025-07-05', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: 'event', + date: '2025-07-05', + startTime: '10:00', + endTime: '11:00', + }), +]; +export const caseEvent2 = [ + createEvent({ + title: 'event1', + date: '2025-06-30', + startTime: '10:00', + endTime: '11:00', + }), + createEvent({ + title: 'event2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + }), + createEvent({ + title: 'event3', + date: '2025-07-31', + startTime: '10:00', + endTime: '11:00', + }), + + createEvent({ + title: 'event4', + date: '2025-08-01', + startTime: '10:00', + endTime: '11:00', + }), +]; +export const caseEvent3 = [ + createEvent({ + title: 'event1', + date: '2025-06-30', + startTime: '10:00', + endTime: '11:00', + id: '1', + notificationTime: 1, + }), + createEvent({ + title: 'event2', + date: '2025-07-20', + startTime: '10:00', + endTime: '11:00', + id: '2', + notificationTime: 1, + }), + createEvent({ + title: 'event3', + date: '2025-06-30', + startTime: '10:00', + endTime: '13:00', + id: '3', + notificationTime: 1, + }), +]; diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 8d784683..f1bfbb98 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -67,12 +67,7 @@ describe('getDaysInMonth', () => { describe('getWeekDates', () => { it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { - const wednesDay = new Date(2025, 7, 20); - - const result = getWeekDates(wednesDay).map( - (date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` - ); + const wednesday = new Date(2025, 7, 20); const expected = [ '2025-08-17', @@ -84,17 +79,13 @@ describe('getWeekDates', () => { '2025-08-23', ]; - expect(result).toEqual(expected); + expect(getWeekDates(wednesday).map((date) => date.toISOString().split('T')[0])).toEqual( + expected + ); }); it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { const monday = new Date(2025, 7, 18); - - const result = getWeekDates(monday).map( - (date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` - ); - const expected = [ '2025-08-17', '2025-08-18', @@ -105,17 +96,11 @@ describe('getWeekDates', () => { '2025-08-23', ]; - expect(result).toEqual(expected); + expect(getWeekDates(monday).map((date) => date.toISOString().split('T')[0])).toEqual(expected); }); it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { const sunday = new Date(2025, 7, 23); - - const result = getWeekDates(sunday).map( - (date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` - ); - const expected = [ '2025-08-17', '2025-08-18', @@ -126,17 +111,11 @@ describe('getWeekDates', () => { '2025-08-23', ]; - expect(result).toEqual(expected); + expect(getWeekDates(sunday).map((date) => date.toISOString().split('T')[0])).toEqual(expected); }); it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { const lastDay = new Date(2025, 11, 31); - - const result = getWeekDates(lastDay).map( - (date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` - ); - const expected = [ '2025-12-28', '2025-12-29', @@ -146,18 +125,12 @@ describe('getWeekDates', () => { '2026-01-02', '2026-01-03', ]; - - expect(result).toEqual(expected); + expect(getWeekDates(lastDay).map((date) => date.toISOString().split('T')[0])).toEqual(expected); }); it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { const startDay = new Date(2026, 0, 1); - const result = getWeekDates(startDay).map( - (date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` - ); - const expected = [ '2025-12-28', '2025-12-29', @@ -168,30 +141,26 @@ describe('getWeekDates', () => { '2026-01-03', ]; - expect(result).toEqual(expected); + expect(getWeekDates(startDay).map((date) => date.toISOString().split('T')[0])).toEqual( + expected + ); }); + // 이거 평일이든 윤년이든 그 해당하는 년도에 똑같이 처리된다고 느껴서 굳이 필요한가 싶어서 주석처리했습니다. it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { - const isLeapYear = (year: number) => { - if (year % 400 === 0) return true; - if (year % 100 === 0) return false; - if (year % 4 === 0) return true; - return false; - }; - - const leapYears = Array.from({ length: 41 }) - .map((_, i) => i + 2020) - .filter((year) => isLeapYear(year)); + const expected = [ + '2024-02-25', + '2024-02-26', + '2024-02-27', + '2024-02-28', + '2024-02-29', + '2024-03-01', + '2024-03-02', + ]; - leapYears.forEach((year) => { - const leapDay = new Date(year, 1, 29); - const result = getWeekDates(leapDay).map( - (date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` - ); - const leapDayString = `${year}-02-29`; - expect(result).toContain(leapDayString); - }); + expect( + getWeekDates(new Date(2024, 1, 29)).map((date) => date.toISOString().split('T')[0]) + ).toEqual(expected); }); it.each([ @@ -222,12 +191,9 @@ describe('getWeekDates', () => { ], }, ])('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', (v) => { - const result = getWeekDates(v.input).map( - (date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + expect(getWeekDates(v.input).map((date) => date.toISOString().split('T')[0])).toEqual( + v.expected ); - - expect(result).toEqual(v.expected); }); }); @@ -375,29 +341,33 @@ describe('fillZero', () => { expect(fillZero(10, 2)).toBe('10'); }); - it("3을 3자리로 변환하면 '003'을 반환한다", () => { - expect(fillZero(3, 3)).toBe('003'); - }); + // 보통은 시간,날짜 두자리정도만 나올거라고 생각이 드는데 3자리 검증테스트는 필요없다고 느낍니다. + // it("3을 3자리로 변환하면 '003'을 반환한다", () => { + // expect(fillZero(3, 3)).toBe('003'); + // }); - it("100을 2자리로 변환하면 '100'을 반환한다", () => { - expect(fillZero(100, 3)).toBe('100'); - }); + // it("100을 2자리로 변환하면 '100'을 반환한다", () => { + // expect(fillZero(100, 3)).toBe('100'); + // }); it("0을 2자리로 변환하면 '00'을 반환한다", () => { expect(fillZero(0, 2)).toBe('00'); }); - it("1을 5자리로 변환하면 '00001'을 반환한다", () => { - expect(fillZero(1, 5)).toBe('00001'); - }); + // 보통은 시간,날짜 두자리정도만 나올거라고 생각이 드는데 5자리 검증테스트는 필요없다고 느낍니다. + // it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + // expect(fillZero(1, 5)).toBe('00001'); + // }); - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { - expect(fillZero(3.14, 5)).toBe('03.14'); - }); + // 굳이 날짜유틸인데 소수점이 들어가나 싶어서 필요 없다고 생각합니다. + // it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + // expect(fillZero(3.14, 5)).toBe('03.14'); + // }); - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { - expect(fillZero(3)).toBe('03'); - }); + // 저는 최대한 2자리만 나온다고 생각해서 굳이 함수도 사이즈를 변수로 안받는게 맞다고 생각해서 이거도 없어도 될거 같습니다. + // it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + // expect(fillZero(3)).toBe('03'); + // }); it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { expect(fillZero(100, 1)).toBe('100'); @@ -414,11 +384,12 @@ describe('formatDate', () => { expect(formatDate(new Date(2025, 6, 1), 20)).toEqual('2025-07-20'); }); - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { - expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); - }); + // 어차피 fillZero에서 하는데 굳이? 0을붙여 포메팅하는게 필요없다고 생각함. + // it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + // expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); + // }); - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { - expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); - }); + // it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + // expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); + // }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 15c3f5dc..00b98ae2 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -6,6 +6,7 @@ import { parseDateTime, } from '../../utils/eventOverlap'; import { expect } from 'vitest'; +import { factoriesEvents, overlapingEvents } from '../__fixture__/eventFactory.ts'; describe('parseDateTime', () => { it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { const date = '2025-07-01'; @@ -29,21 +30,157 @@ describe('parseDateTime', () => { }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const result = convertEventToDateRange({ + id: '1', + title: '기존 회의', + date: '2025-05-01', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }); - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + expect(result).toEqual({ + start: new Date(2025, 4, 1, 9), + end: new Date(2025, 4, 1, 10), + }); + }); + + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const result = convertEventToDateRange({ + id: '1', + title: '기존 회의', + date: '2025-05-2000', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }); + expect(result.end.toString()).toBe('Invalid Date'); + }); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const result = convertEventToDateRange({ + id: '1', + title: '기존 회의', + date: '2025-05-2000', + startTime: '500:000', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }); + + expect(result.end.toString()).toBe('Invalid Date'); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + expect(isOverlapping(overlapingEvents[0], overlapingEvents[1])).toBe(true); + }); - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + expect(isOverlapping(factoriesEvents[0], factoriesEvents[1])).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const existingEvents = [ + { + id: '1', + title: '기존 회의', + date: '2025-05-01', + startTime: '09:00', + endTime: '11:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2025-05-01', + startTime: '10:00', + endTime: '11:00', + description: '동료와 점심', + location: '식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 5, + }, + { + id: '3', + title: '점심 약속', + date: '2025-05-01', + startTime: '10:00', + endTime: '11:00', + description: '동료와 점심', + location: '식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 5, + }, + ] as Event[]; + + expect(findOverlappingEvents(existingEvents[0], existingEvents)).toEqual([ + existingEvents[1], + existingEvents[2], + ]); + }); - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const existingEvents = [ + { + id: '1', + title: '기존 회의', + date: '2025-05-01', + startTime: '09:00', + endTime: '11:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2025-05-03', + startTime: '10:00', + endTime: '11:00', + description: '동료와 점심', + location: '식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 5, + }, + { + id: '3', + title: '점심 약속', + date: '2025-05-04', + startTime: '10:00', + endTime: '11:00', + description: '동료와 점심', + location: '식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 5, + }, + ] as Event[]; + + expect(findOverlappingEvents(existingEvents[0], existingEvents)).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..12cdfafd 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -1,20 +1,105 @@ -import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; +import { caseEvent1, caseEvent2 } from './dummies.ts'; +import { createEvent } from '../__fixture__/eventFactory.ts'; +import { expect } from 'vitest'; + describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + expect(getFilteredEvents(caseEvent1, '이벤트 2', new Date(2025, 6, 1), 'month')).toEqual([ + createEvent({ + title: '이벤트 2', + date: '2025-05-01', + startTime: '09:00', + endTime: '10:00', + }), + ]); + }); - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + expect(getFilteredEvents(caseEvent1, '', new Date(2025, 6, 1), 'week')).toEqual([ + createEvent({ + title: '이벤트 3', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 4', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + }), + ]); + }); - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + expect(getFilteredEvents(caseEvent1, '이벤트', new Date(2025, 6, 1), 'month')).toEqual([ + createEvent({ + title: '이벤트 3', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 4', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + }), + ]); + }); - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + expect(getFilteredEvents(caseEvent1, '이벤트', new Date(2025, 6, 1), 'week')).toEqual([ + createEvent({ + title: '이벤트 3', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 4', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + }), + ]); + }); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + it('검색어가 없을 때 모든 이벤트를 반환한다', () => { + expect(getFilteredEvents(caseEvent1, '', new Date(2025, 6, 1), 'week')).toEqual(caseEvent1); + }); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + expect(getFilteredEvents(caseEvent1, 'EVENT', new Date(2025, 6, 1), 'week')).toEqual([ + createEvent({ + title: 'event', + date: '2025-07-05', + startTime: '10:00', + endTime: '11:00', + }), + ]); + }); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + expect(getFilteredEvents(caseEvent2, '', new Date('2025-07-01'), 'month')).toEqual([ + createEvent({ + title: 'event2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + }), + createEvent({ + title: 'event3', + date: '2025-07-31', + startTime: '10:00', + endTime: '11:00', + }), + ]); + }); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + expect(getFilteredEvents([], '', new Date('2025-07-01'), 'month')).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..ee56d693 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,21 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; +import { expect } from 'vitest'; describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + expect(fetchHolidays(new Date('2025-06-01'))).toEqual({ '2025-06-06': '현충일' }); + }); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + expect(fetchHolidays(new Date('2025-11-01'))).toEqual({}); + }); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + expect(fetchHolidays(new Date('2025-10-01'))).toEqual({ + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-03': '개천절', + '2025-10-09': '한글날', + }); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..5d2f1f2e 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -1,16 +1,85 @@ -import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; +import { caseEvent3 } from './dummies.ts'; +import { createEvent } from '../__fixture__/eventFactory.ts'; describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + expect(getUpcomingEvents(caseEvent3, new Date('2025-06-30T09:59'), [])).toEqual([ + createEvent({ + title: 'event1', + date: '2025-06-30', + startTime: '10:00', + endTime: '11:00', + id: '1', + notificationTime: 1, + }), + createEvent({ + title: 'event3', + date: '2025-06-30', + startTime: '10:00', + endTime: '13:00', + id: '3', + notificationTime: 1, + }), + ]); + }); - it('이미 알림이 간 이벤트는 제외한다', () => {}); + it('이미 알림이 간 이벤트는 제외한다', () => { + expect(getUpcomingEvents(caseEvent3, new Date('2025-06-30T09:59'), ['3'])).toEqual([ + createEvent({ + title: 'event1', + date: '2025-06-30', + startTime: '10:00', + endTime: '11:00', + id: '1', + notificationTime: 1, + }), + ]); + }); - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + expect( + getUpcomingEvents( + [ + createEvent({ + title: 'event1', + date: '2025-06-30', + startTime: '10:00', + endTime: '11:00', + id: '1', + notificationTime: 1, + }), + ], + new Date('2025-06-30T09:00'), + [] + ) + ).toEqual([]); + }); - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + expect(getUpcomingEvents(caseEvent3, new Date('2025-06-30T09:59'), ['2', '3'])).toEqual([ + createEvent({ + title: 'event1', + date: '2025-06-30', + startTime: '10:00', + endTime: '11:00', + id: '1', + notificationTime: 1, + }), + ]); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const event = createEvent({ + title: 'event1', + date: '2025-06-30', + startTime: '10:00', + endTime: '11:00', + id: '1', + notificationTime: 1, + }); + expect(createNotificationMessage(event)).toBe('1분 후 event1 일정이 시작됩니다.'); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..52664644 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,31 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + expect(getTimeErrorMessage('20:00', '19:00').startTimeError).toBe( + '시작 시간은 종료 시간보다 빨라야 합니다.' + ); + }); - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + expect(getTimeErrorMessage('20:00', '20:00').startTimeError).toBe( + '시작 시간은 종료 시간보다 빨라야 합니다.' + ); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + expect(getTimeErrorMessage('18:00', '19:00').startTimeError).toBe(null); + }); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간이 비어있을 때 null을 반환한다', () => { + expect(getTimeErrorMessage('', '19:00').startTimeError).toBe(null); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('종료 시간이 비어있을 때 null을 반환한다', () => { + expect(getTimeErrorMessage('18:00', '').startTimeError).toBe(null); + }); - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + expect(getTimeErrorMessage('', '').startTimeError).toBe(null); + }); }); From 9d22e5b362de71b672fc45703679669cc3fbd9e5 Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 00:39:15 +0900 Subject: [PATCH 04/14] =?UTF-8?q?medium:=20=EA=B8=B0=EB=B3=B8=EA=B3=BC?= =?UTF-8?q?=EC=A0=9C=20=EC=99=84=EB=A3=8C=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20-=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=99=84=EB=A3=8C=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20-=20Hooks=20=20=20=20=20=20=20=20=20=20=20=20=20-=20Unit=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20-=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=20=20=20=20=20=20=20=20-=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20medium=EC=99=84=EB=A3=8C=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20-=20=EC=97=A3=EC=A7=80=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=B3=B4=EC=B6=A9=20=ED=95=84=EC=9A=94=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20eslint/js=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/package.json b/package.json index a3b120bc..236943c2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@eslint/js": "^9.33.0", "@mui/icons-material": "7.2.0", "@mui/material": "7.2.0", "express": "^4.19.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c4888cc..8f067ee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@emotion/styled': specifier: ^11.11.5 version: 11.13.0(@emotion/react@11.13.3(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0) + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 '@mui/icons-material': specifier: 7.2.0 version: 7.2.0(@mui/material@7.2.0(@emotion/react@11.13.3(@types/react@19.1.8)(react@19.1.0))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.8)(react@19.1.0) @@ -486,6 +489,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} @@ -3845,6 +3852,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 0c24b422a372bf6d2baf01878a8991a9bf01eb8f Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 00:46:00 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix:=20caseEvent=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EA=B0=92=EB=8F=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/unit/dummies.ts | 4 +- src/__tests__/unit/easy.eventUtils.spec.ts | 71 ++++++++++++++++++---- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/__tests__/unit/dummies.ts b/src/__tests__/unit/dummies.ts index 42cd4dda..40662909 100644 --- a/src/__tests__/unit/dummies.ts +++ b/src/__tests__/unit/dummies.ts @@ -21,13 +21,13 @@ export const caseEvent1 = [ }), createEvent({ title: '이벤트 4', - date: '2025-07-04', + date: '2025-05-05', startTime: '09:00', endTime: '10:00', }), createEvent({ title: '이벤트 5', - date: '2025-07-05', + date: '2025-06-05', startTime: '09:00', endTime: '10:00', }), diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 12cdfafd..56fd8f70 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -9,7 +9,7 @@ describe('getFilteredEvents', () => { expect(getFilteredEvents(caseEvent1, '이벤트 2', new Date(2025, 6, 1), 'month')).toEqual([ createEvent({ title: '이벤트 2', - date: '2025-05-01', + date: '2025-07-02', startTime: '09:00', endTime: '10:00', }), @@ -19,31 +19,49 @@ describe('getFilteredEvents', () => { it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { expect(getFilteredEvents(caseEvent1, '', new Date(2025, 6, 1), 'week')).toEqual([ createEvent({ - title: '이벤트 3', + title: '이벤트 1', date: '2025-07-01', startTime: '09:00', endTime: '10:00', }), createEvent({ - title: '이벤트 4', - date: '2025-07-01', + title: '이벤트 2', + date: '2025-07-02', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 3', + date: '2025-07-03', startTime: '09:00', endTime: '10:00', }), + createEvent({ + title: 'event', + date: '2025-07-05', + startTime: '10:00', + endTime: '11:00', + }), ]); }); it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { expect(getFilteredEvents(caseEvent1, '이벤트', new Date(2025, 6, 1), 'month')).toEqual([ createEvent({ - title: '이벤트 3', + title: '이벤트 1', date: '2025-07-01', startTime: '09:00', endTime: '10:00', }), createEvent({ - title: '이벤트 4', - date: '2025-07-01', + title: '이벤트 2', + date: '2025-07-02', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 3', + date: '2025-07-03', startTime: '09:00', endTime: '10:00', }), @@ -53,14 +71,20 @@ describe('getFilteredEvents', () => { it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { expect(getFilteredEvents(caseEvent1, '이벤트', new Date(2025, 6, 1), 'week')).toEqual([ createEvent({ - title: '이벤트 3', + title: '이벤트 1', date: '2025-07-01', startTime: '09:00', endTime: '10:00', }), createEvent({ - title: '이벤트 4', - date: '2025-07-01', + title: '이벤트 2', + date: '2025-07-02', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 3', + date: '2025-07-03', startTime: '09:00', endTime: '10:00', }), @@ -68,7 +92,32 @@ describe('getFilteredEvents', () => { }); it('검색어가 없을 때 모든 이벤트를 반환한다', () => { - expect(getFilteredEvents(caseEvent1, '', new Date(2025, 6, 1), 'week')).toEqual(caseEvent1); + expect(getFilteredEvents(caseEvent1, '', new Date(2025, 6, 1), 'week')).toEqual([ + createEvent({ + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 2', + date: '2025-07-02', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: '이벤트 3', + date: '2025-07-03', + startTime: '09:00', + endTime: '10:00', + }), + createEvent({ + title: 'event', + date: '2025-07-05', + startTime: '10:00', + endTime: '11:00', + }), + ]); }); it('검색어가 대소문자를 구분하지 않고 작동한다', () => { From 99a2ecfc69bc3e414ea6c6b692a74a8f6986b75f Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 00:54:23 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20dateUtils=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=92=80=EC=96=B4=EB=B2=84=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/medium.useNotifications.spec.ts | 208 +++++++++--------- src/__tests__/unit/easy.dateUtils.spec.ts | 43 ++-- 2 files changed, 125 insertions(+), 126 deletions(-) diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts index 9a954f2c..a58a026c 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -7,116 +7,114 @@ import { Event } from '../../types.ts'; import { createEvent } from '../__fixture__/eventFactory.ts'; import { expect } from 'vitest'; -describe('useNotifications', () => { - it('초기 상태에서는 알림이 없어야 한다', () => { - const events: Event[] = [ - createEvent({ - id: '1', - date: '2025-10-01', - title: 'event 1', - startTime: '09:00', - endTime: '10:00', - }), - ]; - const { result } = renderHook(() => useNotifications(events)); - - expect(result.current.notifications).toEqual([]); +it('초기 상태에서는 알림이 없어야 한다', () => { + const events: Event[] = [ + createEvent({ + id: '1', + date: '2025-10-01', + title: 'event 1', + startTime: '09:00', + endTime: '10:00', + }), + ]; + const { result } = renderHook(() => useNotifications(events)); + + expect(result.current.notifications).toEqual([]); +}); + +it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + const events: Event[] = [ + createEvent({ + id: '1', + date: '2025-10-01', + title: 'event 1', + startTime: '00:10', + notificationTime: 60, + }), + ]; + + const { result } = renderHook(() => useNotifications(events)); + + // 1초뒤 생성 + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.notifications).toHaveLength(1); + + // 2차: 30분 경과 후에도 기존 알림 유지 + act(() => { + vi.advanceTimersByTime(30 * 60 * 1000); }); + expect(result.current.notifications).toHaveLength(1); // 여전히 1개 + + expect(result.current.notifiedEvents).toEqual(['1']); +}); - it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { - const events: Event[] = [ - createEvent({ - id: '1', - date: '2025-10-01', - title: 'event 1', - startTime: '00:10', - notificationTime: 60, - }), - ]; - - const { result } = renderHook(() => useNotifications(events)); - - // 1초뒤 생성 - act(() => { - vi.advanceTimersByTime(1000); - }); - expect(result.current.notifications).toHaveLength(1); - - // 2차: 30분 경과 후에도 기존 알림 유지 - act(() => { - vi.advanceTimersByTime(30 * 60 * 1000); - }); - expect(result.current.notifications).toHaveLength(1); // 여전히 1개 - - expect(result.current.notifiedEvents).toEqual(['1']); +it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const events: Event[] = [ + createEvent({ + id: '1', + date: '2025-10-01', + title: 'event 1', + startTime: '01:00', + notificationTime: 60, + }), + createEvent({ + id: '2', + date: '2025-10-01', + title: 'event 2', + startTime: '01:00', + notificationTime: 60, + }), + ]; + const { result } = renderHook(() => useNotifications(events)); + + act(() => { + vi.advanceTimersByTime(1000); }); - it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { - const events: Event[] = [ - createEvent({ - id: '1', - date: '2025-10-01', - title: 'event 1', - startTime: '01:00', - notificationTime: 60, - }), - createEvent({ - id: '2', - date: '2025-10-01', - title: 'event 2', - startTime: '01:00', - notificationTime: 60, - }), - ]; - const { result } = renderHook(() => useNotifications(events)); - - act(() => { - vi.advanceTimersByTime(1000); - }); - - act(() => { - result.current.removeNotification(1); - }); - - expect(result.current.notifications).toEqual([ - { - id: '1', - message: '60분 후 event 1 일정이 시작됩니다.', - }, - ]); - expect(result.current.notifiedEvents).toEqual(['1', '2']); + act(() => { + result.current.removeNotification(1); }); - it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { - vi.setSystemTime(new Date('2025-10-01T00:59:00')); // 1분 전 - const events: Event[] = [ - createEvent({ - id: '1', - date: '2025-10-01', - title: 'event 1', - startTime: '01:00', - notificationTime: 60, - }), - ]; - - const { result } = renderHook(() => useNotifications(events)); - - act(() => { - vi.advanceTimersByTime(1000); - }); - // 발생 이벤트 확인 - expect(result.current.notifiedEvents).toEqual(['1']); - - // 두번째 시도 - act(() => { - vi.advanceTimersByTime(2000); - }); - // 다시 확인 - expect(result.current.notifiedEvents).toEqual(['1']); - // 세번째 시도 - act(() => { - vi.advanceTimersByTime(3000); - }); - expect(result.current.notifiedEvents).toEqual(['1']); + expect(result.current.notifications).toEqual([ + { + id: '1', + message: '60분 후 event 1 일정이 시작됩니다.', + }, + ]); + expect(result.current.notifiedEvents).toEqual(['1', '2']); +}); + +it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + vi.setSystemTime(new Date('2025-10-01T00:59:00')); // 1분 전 + const events: Event[] = [ + createEvent({ + id: '1', + date: '2025-10-01', + title: 'event 1', + startTime: '01:00', + notificationTime: 60, + }), + ]; + + const { result } = renderHook(() => useNotifications(events)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + // 발생 이벤트 확인 + expect(result.current.notifiedEvents).toEqual(['1']); + + // 두번째 시도 + act(() => { + vi.advanceTimersByTime(2000); + }); + // 다시 확인 + expect(result.current.notifiedEvents).toEqual(['1']); + // 세번째 시도 + act(() => { + vi.advanceTimersByTime(3000); }); + expect(result.current.notifiedEvents).toEqual(['1']); }); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index f1bfbb98..b18d415f 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -342,32 +342,33 @@ describe('fillZero', () => { }); // 보통은 시간,날짜 두자리정도만 나올거라고 생각이 드는데 3자리 검증테스트는 필요없다고 느낍니다. - // it("3을 3자리로 변환하면 '003'을 반환한다", () => { - // expect(fillZero(3, 3)).toBe('003'); - // }); + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + expect(fillZero(3, 3)).toBe('003'); + }); - // it("100을 2자리로 변환하면 '100'을 반환한다", () => { - // expect(fillZero(100, 3)).toBe('100'); - // }); + // 보통은 시간,날짜 두자리정도만 나올거라고 생각이 드는데 3자리 검증테스트는 필요없다고 느낍니다. + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + expect(fillZero(100, 3)).toBe('100'); + }); it("0을 2자리로 변환하면 '00'을 반환한다", () => { expect(fillZero(0, 2)).toBe('00'); }); // 보통은 시간,날짜 두자리정도만 나올거라고 생각이 드는데 5자리 검증테스트는 필요없다고 느낍니다. - // it("1을 5자리로 변환하면 '00001'을 반환한다", () => { - // expect(fillZero(1, 5)).toBe('00001'); - // }); + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + expect(fillZero(1, 5)).toBe('00001'); + }); // 굳이 날짜유틸인데 소수점이 들어가나 싶어서 필요 없다고 생각합니다. - // it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { - // expect(fillZero(3.14, 5)).toBe('03.14'); - // }); + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + expect(fillZero(3.14, 5)).toBe('03.14'); + }); // 저는 최대한 2자리만 나온다고 생각해서 굳이 함수도 사이즈를 변수로 안받는게 맞다고 생각해서 이거도 없어도 될거 같습니다. - // it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { - // expect(fillZero(3)).toBe('03'); - // }); + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + expect(fillZero(3)).toBe('03'); + }); it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { expect(fillZero(100, 1)).toBe('100'); @@ -385,11 +386,11 @@ describe('formatDate', () => { }); // 어차피 fillZero에서 하는데 굳이? 0을붙여 포메팅하는게 필요없다고 생각함. - // it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { - // expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); - // }); + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); + }); - // it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { - // expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); - // }); + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date(2025, 6, 1))).toEqual('2025-07-01'); + }); }); From 05476a34b76787c5b0804745c12c747fc1a88c01 Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 00:58:31 +0900 Subject: [PATCH 07/14] =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/hooks/easy.useSearch.spec.ts | 2 +- src/__tests__/hooks/medium.useEventOperations.spec.ts | 2 +- src/__tests__/hooks/medium.useNotifications.spec.ts | 2 +- src/__tests__/medium.integration.spec.tsx | 5 +++-- src/__tests__/unit/easy.dateUtils.spec.ts | 3 ++- src/__tests__/unit/easy.eventOverlap.spec.ts | 3 ++- src/__tests__/unit/easy.eventUtils.spec.ts | 4 ++-- src/__tests__/unit/easy.fetchHolidays.spec.ts | 4 +++- src/__tests__/unit/easy.notificationUtils.spec.ts | 2 +- 9 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 411259ac..9324b38e 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -1,7 +1,7 @@ import { act, renderHook } from '@testing-library/react'; +import { expect } from 'vitest'; import { useSearch } from '../../hooks/useSearch.ts'; -import { expect } from 'vitest'; import { createEvent } from '../__fixture__/eventFactory.ts'; it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 5e5c15fb..b7eabe93 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -1,5 +1,6 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; +import { expect } from 'vitest'; import { setupMockHandlerCreation, @@ -10,7 +11,6 @@ import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; import { EventForm } from '../../types.ts'; import { createEvent } from '../__fixture__/eventFactory.ts'; -import { expect } from 'vitest'; const enqueueSnackbarFn = vi.fn(); diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts index a58a026c..005e32db 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -1,11 +1,11 @@ import { act, renderHook } from '@testing-library/react'; +import { expect } from 'vitest'; import { useNotifications } from '../../hooks/useNotifications.ts'; import { Event } from '../../types.ts'; // import { formatDate } from '../../utils/dateUtils.ts'; // import { parseHM } from '../utils.ts'; import { createEvent } from '../__fixture__/eventFactory.ts'; -import { expect } from 'vitest'; it('초기 상태에서는 알림이 없어야 한다', () => { const events: Event[] = [ diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index b6b6eb28..a93a805a 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -5,6 +5,9 @@ import { UserEvent, userEvent } from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; import { ReactElement } from 'react'; +import { expect } from 'vitest'; +import { debug } from 'vitest-preview'; + import { setupMockHandlerCreation, setupMockHandlerDeletion, @@ -13,8 +16,6 @@ import { import App from '../App'; import { server } from '../setupTests'; import { Event } from '../types'; -import { debug } from 'vitest-preview'; -import { expect } from 'vitest'; const theme = createTheme(); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index b18d415f..daf081a9 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -1,3 +1,5 @@ +import { expect } from 'vitest'; + import { Event } from '../../types'; import { fillZero, @@ -10,7 +12,6 @@ import { getWeeksAtMonth, isDateInRange, } from '../../utils/dateUtils'; -import { expect } from 'vitest'; import { factoriesEvents } from '../__fixture__/eventFactory.ts'; describe('getDaysInMonth', () => { diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 00b98ae2..e6f4ce5e 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -1,3 +1,5 @@ +import { expect } from 'vitest'; + import { Event } from '../../types'; import { convertEventToDateRange, @@ -5,7 +7,6 @@ import { isOverlapping, parseDateTime, } from '../../utils/eventOverlap'; -import { expect } from 'vitest'; import { factoriesEvents, overlapingEvents } from '../__fixture__/eventFactory.ts'; describe('parseDateTime', () => { it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 56fd8f70..0e7c3a17 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -1,8 +1,8 @@ -import { getFilteredEvents } from '../../utils/eventUtils'; +import { expect } from 'vitest'; import { caseEvent1, caseEvent2 } from './dummies.ts'; +import { getFilteredEvents } from '../../utils/eventUtils'; import { createEvent } from '../__fixture__/eventFactory.ts'; -import { expect } from 'vitest'; describe('getFilteredEvents', () => { it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index ee56d693..94f4ba72 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,5 +1,7 @@ -import { fetchHolidays } from '../../apis/fetchHolidays'; import { expect } from 'vitest'; + +import { fetchHolidays } from '../../apis/fetchHolidays'; + describe('fetchHolidays', () => { it('주어진 월의 공휴일만 반환한다', () => { expect(fetchHolidays(new Date('2025-06-01'))).toEqual({ '2025-06-06': '현충일' }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 5d2f1f2e..07347774 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -1,5 +1,5 @@ -import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; import { caseEvent3 } from './dummies.ts'; +import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; import { createEvent } from '../__fixture__/eventFactory.ts'; describe('getUpcomingEvents', () => { From 2e7793d7b7bbdee676184998ebd7b9a588fd1c8c Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 00:58:56 +0900 Subject: [PATCH 08/14] =?UTF-8?q?fix:=20=EB=A6=B0=ED=8A=B8=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__/unit/easy.dateUtils.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index daf081a9..224770c6 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -17,13 +17,11 @@ import { factoriesEvents } from '../__fixture__/eventFactory.ts'; describe('getDaysInMonth', () => { it('1월은 31일 수를 반환한다', () => { const result = getDaysInMonth(2025, 1); - expect(result).toBe(31); }); it('4월은 30일 일수를 반환한다', () => { const result = getDaysInMonth(2025, 4); - expect(result).toBe(30); }); From cf31ba39d7cad2974f76fc6eeb2094e6f4ac01a0 Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 01:32:29 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=EB=A6=B0=ED=8A=B8=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__/hooks/medium.useEventOperations.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index b7eabe93..13d07276 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -108,15 +108,16 @@ it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함 // 일부러 에러내기 server.use( http.get('/api/events', () => { - return HttpResponse.error(); + return new HttpResponse(null, { status: 500 }); }) ); - renderHook(() => useEventOperations(false, enqueueSnackbarFn)); + const { result } = renderHook(() => useEventOperations(false)); - await waitFor(() => { - expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); + await waitFor(async () => { + await result.current.fetchEvents(); }); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); }); it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { From fc4078e1917dc6d76887096ae9e0329fca46253d Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 01:34:38 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20useEventOperations.spec.ts=20serve?= =?UTF-8?q?r.use=20return=20=EC=97=90=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__/hooks/medium.useEventOperations.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 13d07276..3dc4546f 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -159,6 +159,7 @@ it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되 ); const { result } = renderHook(() => useEventOperations(false, enqueueSnackbarFn)); + await act(async () => { await result.current.deleteEvent('1'); }); From bec7c0f051d112d4ebb74ac4000e0cba2c47edeb Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 02:08:29 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20useEventOperations.spec.ts=20serve?= =?UTF-8?q?r.use=20return=20=EC=97=90=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/App.tsx | 679 +++------------------------ src/components/CalendarView.tsx | 246 ++++++++++ src/components/EventForm.tsx | 224 +++++++++ src/components/EventList.tsx | 113 +++++ src/components/NotificationStack.tsx | 36 ++ src/components/OverlapDialog.tsx | 48 ++ src/components/index.ts | 5 + src/hooks/useEventForm.ts | 70 +-- 8 files changed, 774 insertions(+), 647 deletions(-) create mode 100644 src/components/CalendarView.tsx create mode 100644 src/components/EventForm.tsx create mode 100644 src/components/EventList.tsx create mode 100644 src/components/NotificationStack.tsx create mode 100644 src/components/OverlapDialog.tsx create mode 100644 src/components/index.ts diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..5d7977db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,98 +1,21 @@ -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 } from '@mui/material'; import { useSnackbar } from 'notistack'; import { useState } from 'react'; -import { useCalendarView } from './hooks/useCalendarView.ts'; -import { useEventForm } from './hooks/useEventForm.ts'; -import { useEventOperations } from './hooks/useEventOperations.ts'; -import { useNotifications } from './hooks/useNotifications.ts'; -import { useSearch } from './hooks/useSearch.ts'; -// import { Event, EventForm, RepeatType } from './types'; -import { Event, EventForm } from './types'; -import { - formatDate, - formatMonth, - formatWeek, - getEventsForDay, - getWeekDates, - getWeeksAtMonth, -} from './utils/dateUtils'; +import { CalendarView, EventForm, EventList, NotificationStack, OverlapDialog } from './components'; +import { useCalendarView } from './hooks/useCalendarView'; +import { useEventForm } from './hooks/useEventForm'; +import { useEventOperations } from './hooks/useEventOperations'; +import { useNotifications } from './hooks/useNotifications'; +import { useSearch } from './hooks/useSearch'; +import { Event, EventForm as EventFormType } from './types'; import { findOverlappingEvents } from './utils/eventOverlap'; -import { getTimeErrorMessage } from './utils/timeValidation'; - -const categories = ['업무', '개인', '가족', '기타']; - -const weekDays = ['일', '월', '화', '수', '목', '금', '토']; - -const notificationOptions = [ - { value: 1, label: '1분 전' }, - { value: 10, label: '10분 전' }, - { value: 60, label: '1시간 전' }, - { value: 120, label: '2시간 전' }, - { value: 1440, label: '1일 전' }, -]; function App() { - const { - title, - setTitle, - date, - setDate, - startTime, - endTime, - description, - setDescription, - location, - setLocation, - category, - setCategory, - isRepeating, - setIsRepeating, - repeatType, - // setRepeatType, - repeatInterval, - // setRepeatInterval, - repeatEndDate, - // setRepeatEndDate, - notificationTime, - setNotificationTime, - startTimeError, - endTimeError, - editingEvent, - setEditingEvent, - handleStartTimeChange, - handleEndTimeChange, - resetForm, - editEvent, - } = useEventForm(); + const [editingEvent, setEditingEvent] = useState(null); + const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); + const [overlappingEvents, setOverlappingEvents] = useState([]); + const [pendingEventData, setPendingEventData] = useState(null); const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () => setEditingEvent(null) @@ -101,558 +24,82 @@ function App() { const { notifications, notifiedEvents, setNotifications } = useNotifications(events); 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 { editEvent } = useEventForm(editingEvent, setEditingEvent); const { enqueueSnackbar } = useSnackbar(); - const addOrUpdateEvent = async () => { - if (!title || !date || !startTime || !endTime) { + const handleSaveEvent = async (eventData: Event | EventFormType) => { + if (!eventData.title || !eventData.date || !eventData.startTime || !eventData.endTime) { enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); return; } - if (startTimeError || endTimeError) { - enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); - return; - } - - const eventData: Event | EventForm = { - id: editingEvent ? editingEvent.id : undefined, - title, - date, - startTime, - endTime, - description, - location, - category, - repeat: { - type: isRepeating ? repeatType : 'none', - interval: repeatInterval, - endDate: repeatEndDate || undefined, - }, - notificationTime, - }; - const overlapping = findOverlappingEvents(eventData, events); if (overlapping.length > 0) { + setPendingEventData(eventData); setOverlappingEvents(overlapping); setIsOverlapDialogOpen(true); } else { await saveEvent(eventData); - resetForm(); } }; - 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); + const handleOverlapContinue = async () => { + setIsOverlapDialogOpen(false); - 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]; + if (pendingEventData) { + await saveEvent(pendingEventData); + setPendingEventData(null); + } + }; - return ( - - {day && ( - <> - - {day} - - {holiday && ( - - {holiday} - - )} - {getEventsForDay(filteredEvents, day).map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - )} - - ); - })} - - ))} - -
-
-
- ); + const handleRemoveNotification = (index: number) => { + setNotifications((prev) => prev.filter((_, i) => i !== index)); }; 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)} - /> - - - {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); + setPendingEventData(null); + }} + onContinue={handleOverlapContinue} + /> + + ); } diff --git a/src/components/CalendarView.tsx b/src/components/CalendarView.tsx new file mode 100644 index 00000000..2e7d879b --- /dev/null +++ b/src/components/CalendarView.tsx @@ -0,0 +1,246 @@ +import { Notifications, ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { + Box, + IconButton, + MenuItem, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { Event } from '../types'; +import { + formatDate, + formatMonth, + formatWeek, + getEventsForDay, + getWeekDates, + getWeeksAtMonth, +} from '../utils/dateUtils'; + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +interface CalendarViewProps { + view: 'week' | 'month'; + currentDate: Date; + events: Event[]; + notifiedEvents: string[]; + holidays: Record; + onViewChange: (view: 'week' | 'month') => void; + onNavigate: (direction: 'prev' | 'next') => void; +} + +export function CalendarView({ + view, + currentDate, + events, + notifiedEvents, + holidays, + onViewChange, + onNavigate, +}: CalendarViewProps) { + const renderWeekView = () => { + const weekDates = getWeekDates(currentDate); + return ( + + {formatWeek(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + + {weekDates.map((date) => ( + + + {date.getDate()} + + {events + .filter( + (event) => new Date(event.date).toDateString() === date.toDateString() + ) + .map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + ))} + + +
+
+
+ ); + }; + + const renderMonthView = () => { + const weeks = getWeeksAtMonth(currentDate); + + return ( + + {formatMonth(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + {weeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + const dateString = day ? formatDate(currentDate, day) : ''; + const holiday = holidays[dateString]; + + return ( + + {day && ( + <> + + {day} + + {holiday && ( + + {holiday} + + )} + {getEventsForDay(events, day).map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + )} + + ); + })} + + ))} + +
+
+
+ ); + }; + + return ( + + 일정 보기 + + + onNavigate('prev')}> + + + + onNavigate('next')}> + + + + + {view === 'week' && renderWeekView()} + {view === 'month' && renderMonthView()} + + ); +} diff --git a/src/components/EventForm.tsx b/src/components/EventForm.tsx new file mode 100644 index 00000000..240450c6 --- /dev/null +++ b/src/components/EventForm.tsx @@ -0,0 +1,224 @@ +import { + Button, + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; + +import { useEventForm } from '../hooks/useEventForm'; +import { Event, EventForm as EventFormType } from '../types'; + +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 { + onSave: (eventData: Event | EventFormType) => Promise; + editingEvent: Event | null; + onEditingEventChange: (event: Event | null) => void; +} + +export function EventForm({ onSave, editingEvent, onEditingEventChange }: EventFormProps) { + const { + title, + setTitle, + date, + setDate, + startTime, + endTime, + description, + setDescription, + location, + setLocation, + category, + setCategory, + isRepeating, + setIsRepeating, + repeatType, + repeatInterval, + repeatEndDate, + notificationTime, + setNotificationTime, + startTimeError, + endTimeError, + handleStartTimeChange, + handleEndTimeChange, + resetForm, + } = useEventForm(editingEvent, onEditingEventChange); + + const { enqueueSnackbar } = useSnackbar(); + + const handleSubmit = async () => { + if (!title || !date || !startTime || !endTime) { + enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); + return; + } + + if (startTimeError || endTimeError) { + enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); + return; + } + + const eventData: Event | EventFormType = { + id: editingEvent ? editingEvent.id : undefined, + title, + date, + startTime, + endTime, + description, + location, + category, + repeat: { + type: isRepeating ? repeatType : 'none', + interval: repeatInterval, + endDate: repeatEndDate || undefined, + }, + notificationTime, + }; + + await onSave(eventData); + resetForm(); + }; + + return ( + + {editingEvent ? '일정 수정' : '일정 추가'} + + + 제목 + setTitle(e.target.value)} + /> + + + + 날짜 + setDate(e.target.value)} + /> + + + + + 시작 시간 + + + + + + 종료 시간 + + + + + + + + 설명 + 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..12cdc3c8 --- /dev/null +++ b/src/components/EventList.tsx @@ -0,0 +1,113 @@ +import { Delete, Edit, Notifications } from '@mui/icons-material'; +import { + Box, + FormControl, + FormLabel, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { Event } from '../types'; + +const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +interface EventListProps { + events: Event[]; + notifiedEvents: string[]; + searchTerm: string; + onSearchChange: (term: string) => void; + onEditEvent: (event: Event) => void; + onDeleteEvent: (id: string) => Promise; +} + +export function EventList({ + events, + notifiedEvents, + searchTerm, + onSearchChange, + onEditEvent, + onDeleteEvent, +}: EventListProps) { + return ( + + + 일정 검색 + onSearchChange(e.target.value)} + /> + + + {events.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + events.map((event) => ( + + + + + {notifiedEvents.includes(event.id) && } + + {event.title} + + + {event.date} + + {event.startTime} - {event.endTime} + + {event.description} + {event.location} + 카테고리: {event.category} + {event.repeat.type !== 'none' && ( + + 반복: {event.repeat.interval} + {event.repeat.type === 'daily' && '일'} + {event.repeat.type === 'weekly' && '주'} + {event.repeat.type === 'monthly' && '월'} + {event.repeat.type === 'yearly' && '년'} + 마다 + {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`} + + )} + + 알림:{' '} + { + notificationOptions.find( + (option) => option.value === event.notificationTime + )?.label + } + + + + onEditEvent(event)}> + + + onDeleteEvent(event.id)}> + + + + + + )) + )} + + ); +} diff --git a/src/components/NotificationStack.tsx b/src/components/NotificationStack.tsx new file mode 100644 index 00000000..0a259ebc --- /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[]; + onRemoveNotification: (index: number) => void; +} + +export function NotificationStack({ notifications, onRemoveNotification }: NotificationStackProps) { + if (notifications.length === 0) { + return null; + } + + return ( + + {notifications.map((notification, index) => ( + onRemoveNotification(index)}> + + + } + > + {notification.message} + + ))} + + ); +} diff --git a/src/components/OverlapDialog.tsx b/src/components/OverlapDialog.tsx new file mode 100644 index 00000000..ba85a70a --- /dev/null +++ b/src/components/OverlapDialog.tsx @@ -0,0 +1,48 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from '@mui/material'; + +import { Event } from '../types'; + +interface OverlapDialogProps { + open: boolean; + overlappingEvents: Event[]; + onClose: () => void; + onContinue: () => void; +} + +export function OverlapDialog({ + open, + overlappingEvents, + onClose, + onContinue, +}: OverlapDialogProps) { + return ( + + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +} diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 00000000..78868eba --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,5 @@ +export { CalendarView } from './CalendarView'; +export { EventForm } from './EventForm'; +export { EventList } from './EventList'; +export { NotificationStack } from './NotificationStack'; +export { OverlapDialog } from './OverlapDialog'; diff --git a/src/hooks/useEventForm.ts b/src/hooks/useEventForm.ts index 9dfcc46a..bb8f3487 100644 --- a/src/hooks/useEventForm.ts +++ b/src/hooks/useEventForm.ts @@ -1,31 +1,52 @@ -import { ChangeEvent, useState } from 'react'; +import { ChangeEvent, useEffect, useState } from 'react'; import { Event, RepeatType } from '../types'; import { getTimeErrorMessage } from '../utils/timeValidation'; type TimeErrorRecord = Record<'startTimeError' | 'endTimeError', string | null>; -export const useEventForm = (initialEvent?: Event) => { - const [title, setTitle] = useState(initialEvent?.title || ''); - const [date, setDate] = useState(initialEvent?.date || ''); - const [startTime, setStartTime] = useState(initialEvent?.startTime || ''); - const [endTime, setEndTime] = useState(initialEvent?.endTime || ''); - const [description, setDescription] = useState(initialEvent?.description || ''); - const [location, setLocation] = useState(initialEvent?.location || ''); - const [category, setCategory] = useState(initialEvent?.category || '업무'); - const [isRepeating, setIsRepeating] = useState(initialEvent?.repeat.type !== 'none'); - const [repeatType, setRepeatType] = useState(initialEvent?.repeat.type || 'none'); - const [repeatInterval, setRepeatInterval] = useState(initialEvent?.repeat.interval || 1); - const [repeatEndDate, setRepeatEndDate] = useState(initialEvent?.repeat.endDate || ''); - const [notificationTime, setNotificationTime] = useState(initialEvent?.notificationTime || 10); - - const [editingEvent, setEditingEvent] = useState(null); +export const useEventForm = ( + editingEvent?: Event | null, + onEditingEventChange?: (event: Event | null) => void +) => { + const [title, setTitle] = useState(''); + const [date, setDate] = useState(''); + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [description, setDescription] = useState(''); + const [location, setLocation] = useState(''); + const [category, setCategory] = useState('업무'); + const [isRepeating, setIsRepeating] = useState(false); + const [repeatType, setRepeatType] = useState('none'); + const [repeatInterval, setRepeatInterval] = useState(1); + const [repeatEndDate, setRepeatEndDate] = useState(''); + const [notificationTime, setNotificationTime] = useState(10); const [{ startTimeError, endTimeError }, setTimeError] = useState({ startTimeError: null, endTimeError: null, }); + // editingEvent가 변경될 때마다 폼 데이터 업데이트 + useEffect(() => { + if (editingEvent) { + setTitle(editingEvent.title); + setDate(editingEvent.date); + setStartTime(editingEvent.startTime); + setEndTime(editingEvent.endTime); + setDescription(editingEvent.description); + setLocation(editingEvent.location); + setCategory(editingEvent.category); + setIsRepeating(editingEvent.repeat.type !== 'none'); + setRepeatType(editingEvent.repeat.type); + setRepeatInterval(editingEvent.repeat.interval); + setRepeatEndDate(editingEvent.repeat.endDate || ''); + setNotificationTime(editingEvent.notificationTime); + } else { + resetForm(); + } + }, [editingEvent]); + const handleStartTimeChange = (e: ChangeEvent) => { const newStartTime = e.target.value; setStartTime(newStartTime); @@ -51,22 +72,11 @@ export const useEventForm = (initialEvent?: Event) => { setRepeatInterval(1); setRepeatEndDate(''); setNotificationTime(10); + onEditingEventChange?.(null); }; const editEvent = (event: Event) => { - setEditingEvent(event); - setTitle(event.title); - setDate(event.date); - setStartTime(event.startTime); - setEndTime(event.endTime); - setDescription(event.description); - setLocation(event.location); - setCategory(event.category); - setIsRepeating(event.repeat.type !== 'none'); - setRepeatType(event.repeat.type); - setRepeatInterval(event.repeat.interval); - setRepeatEndDate(event.repeat.endDate || ''); - setNotificationTime(event.notificationTime); + onEditingEventChange?.(event); }; return { @@ -96,8 +106,6 @@ export const useEventForm = (initialEvent?: Event) => { setNotificationTime, startTimeError, endTimeError, - editingEvent, - setEditingEvent, handleStartTimeChange, handleEndTimeChange, resetForm, From 3c9a2c5419f8bbe901a2c94d9ab016fa12c457c2 Mon Sep 17 00:00:00 2001 From: YangS1s Date: Fri, 22 Aug 2025 04:16:44 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20useEventOperations.spec.ts=20serve?= =?UTF-8?q?r.use=20return=20=EC=97=90=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/App.tsx | 4 +- src/__tests__/components/EventForm.test.tsx | 63 +++++++++++++++++++++ src/components/{EventForm.tsx => Form.tsx} | 9 ++- src/components/index.ts | 2 +- 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/components/EventForm.test.tsx rename src/components/{EventForm.tsx => Form.tsx} (95%) diff --git a/src/App.tsx b/src/App.tsx index 5d7977db..29524a5c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { Box, Stack } from '@mui/material'; import { useSnackbar } from 'notistack'; import { useState } from 'react'; -import { CalendarView, EventForm, EventList, NotificationStack, OverlapDialog } from './components'; +import { CalendarView, Form, EventList, NotificationStack, OverlapDialog } from './components'; import { useCalendarView } from './hooks/useCalendarView'; import { useEventForm } from './hooks/useEventForm'; import { useEventOperations } from './hooks/useEventOperations'; @@ -60,7 +60,7 @@ function App() { return ( - { + const user = userEvent.setup(); + const defaultProps = { + onSave: vi.fn(), + editingEvent: null, + onEditingEventChange: vi.fn(), + }; + return { + ...render( + + +
+ + + ), + user, + }; +}; + +describe('EventForm', () => { + it('필수 필드가 채워져있지 않으면, 에러 토스트가 나옴', async () => { + const { user } = setup(); + // 빈 폼으로 제출 버튼 클릭 + const submitButton = screen.getByRole('button', { name: '일정 추가' }); + + await user.click(submitButton); + // 에러 토스트 메시지 확인 + expect(await screen.findByText('필수 정보를 모두 입력해주세요.')).toBeInTheDocument(); + }); + + // 반복 일정 체크박스 + it('반복 일정 체크박스가 올바르게 작동한다', async () => { + const { user } = setup(); + + // 반복 일정 체크박스 찾기 + const repeatCheckbox = screen.getByRole('checkbox'); + + // 초기 상태: 체크되지 않음 + expect(repeatCheckbox).not.toBeChecked(); + + // 체크박스 클릭 + await user.click(repeatCheckbox); + + // 체크된 상태로 변경됨 + expect(repeatCheckbox).toBeChecked(); + + // 다시 클릭 + await user.click(repeatCheckbox); + + // 체크 해제됨 + expect(repeatCheckbox).not.toBeChecked(); + }); +}); diff --git a/src/components/EventForm.tsx b/src/components/Form.tsx similarity index 95% rename from src/components/EventForm.tsx rename to src/components/Form.tsx index 240450c6..8dddb3ed 100644 --- a/src/components/EventForm.tsx +++ b/src/components/Form.tsx @@ -32,7 +32,7 @@ interface EventFormProps { onEditingEventChange: (event: Event | null) => void; } -export function EventForm({ onSave, editingEvent, onEditingEventChange }: EventFormProps) { +export function Form({ onSave, editingEvent, onEditingEventChange }: EventFormProps) { const { title, setTitle, @@ -189,7 +189,11 @@ export function EventForm({ onSave, editingEvent, onEditingEventChange }: EventF setIsRepeating(e.target.checked)} /> + setIsRepeating(e.target.checked)} + /> } label="반복 일정" /> @@ -200,6 +204,7 @@ export function EventForm({ onSave, editingEvent, onEditingEventChange }: EventF