diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..ff9fae69 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,91 @@ +### +# Place your Prettier ignore content here + +### +# .gitignore content is duplicated here due to https://github.com/prettier/prettier/issues/8506 + +# Created by .ignore support plugin (hsz.mobi) + +### Node template + +# Logs +/logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage +coverage +.nyc_output + +# Grunt intermediate storage +.grunt + +# Bower dependency directory +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons +build/Release + +# Dependency directories +node_modules/ +custom_modules + +# dotenv environment variables +env/ + +# Build artifacts +build +dist + +# Next.js +.next +# .next is already above (uncommented version preferred) + +# Deployment +.vercel + +# Editor / IDE +.idea +.vscode +*.code-workspace + +# Ignore dependency locks +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Static/public assets +public +next-env.d.ts + +# Business-specific files +gtag.ts + +# Jest snapshots +*.test.ts.snap +*.test.tsx.snap + +# macOS / OS metadata +.DS_Store +Thumbs.db + +# Prettier config +.prettierignore +.prettierrc + +# Husky setting +.husky/ \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 0a019971..36ca9cdd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -70,7 +70,8 @@ export default [ ...typescriptPlugin.configs.recommended.rules, // ESLint rules - 'no-unused-vars': 'warn', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error'], // React rules 'react/prop-types': 'off', diff --git a/index.html b/index.html index 11222ae1..9ed34aa1 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,12 @@ - - - - 일정관리 앱으로 학습하는 테스트 코드 - - -
- - + + + + 일정관리 앱으로 학습하는 테스트 코드 + + +
+ + diff --git a/package.json b/package.json index b01b2b4b..0dee0853 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "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", + "format": "prettier --check .", + "format:fix": "prettier --write --list-different ." }, "dependencies": { "@emotion/react": "^11.11.4", @@ -29,6 +31,7 @@ "react-dom": "19.1.0" }, "devDependencies": { + "@eslint/js": "^9.33.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.5.2", @@ -51,6 +54,7 @@ "eslint-plugin-vitest": "^0.5.4", "globals": "16.3.0", "jsdom": "^26.1.0", + "prettier": "3.6.2", "typescript": "^5.2.2", "vite": "^7.0.2", "vite-plugin-eslint": "^1.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093f3ec7..cd3c64eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: specifier: 19.1.0 version: 19.1.0(react@19.1.0) devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -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) @@ -105,6 +108,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + prettier: + specifier: 3.6.2 + version: 3.6.2 typescript: specifier: ^5.2.2 version: 5.6.3 @@ -468,6 +474,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} @@ -2562,8 +2572,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 @@ -3628,6 +3638,8 @@ snapshots: '@eslint/js@9.30.0': {} + '@eslint/js@9.33.0': {} + '@eslint/object-schema@2.1.6': {} '@eslint/plugin-kit@0.3.3': @@ -4958,10 +4970,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 +5006,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 +5996,7 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.3.3: {} + prettier@3.6.2: {} pretty-format@27.5.1: dependencies: @@ -6331,7 +6343,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 +6357,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/App.tsx b/src/App.tsx index 195c5b05..05ee3358 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,14 +4,12 @@ import { AlertTitle, Box, Button, - Checkbox, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControl, - FormControlLabel, FormLabel, IconButton, MenuItem, @@ -24,19 +22,19 @@ import { TableHead, TableRow, TextField, - Tooltip, Typography, } from '@mui/material'; import { useSnackbar } from 'notistack'; import { useState } from 'react'; +import EventForm from './components/EventForm'; +import { notificationOptions } from './constants/notifications'; 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 { Event, EventForm as EventFormType } from './types'; import { formatDate, formatMonth, @@ -46,20 +44,9 @@ import { getWeeksAtMonth, } from './utils/dateUtils'; import { findOverlappingEvents } from './utils/eventOverlap'; -import { getTimeErrorMessage } from './utils/timeValidation'; - -const categories = ['업무', '개인', '가족', '기타']; const weekDays = ['일', '월', '화', '수', '목', '금', '토']; -const notificationOptions = [ - { value: 1, label: '1분 전' }, - { value: 10, label: '10분 전' }, - { value: 60, label: '1시간 전' }, - { value: 120, label: '2시간 전' }, - { value: 1440, label: '1일 전' }, -]; - function App() { const { title, @@ -77,11 +64,8 @@ function App() { isRepeating, setIsRepeating, repeatType, - // setRepeatType, repeatInterval, - // setRepeatInterval, repeatEndDate, - // setRepeatEndDate, notificationTime, setNotificationTime, startTimeError, @@ -118,7 +102,7 @@ function App() { return; } - const eventData: Event | EventForm = { + const eventData: Event | EventFormType = { id: editingEvent ? editingEvent.id : undefined, title, date, @@ -316,176 +300,30 @@ function App() { return ( - - {editingEvent ? '일정 수정' : '일정 추가'} - - - 제목 - setTitle(e.target.value)} - /> - - - - 날짜 - setDate(e.target.value)} - /> - - - - - 시작 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!startTimeError} - /> - - - - 종료 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!endTimeError} - /> - - - - - - 설명 - setDescription(e.target.value)} - /> - - - - 위치 - setLocation(e.target.value)} - /> - - - - 카테고리 - - - - - setIsRepeating(e.target.checked)} - /> - } - label="반복 일정" - /> - - - - 알림 설정 - - - - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( - - - 반복 유형 - - - - - 반복 간격 - setRepeatInterval(Number(e.target.value))} - slotProps={{ htmlInput: { min: 1 } }} - /> - - - 반복 종료일 - setRepeatEndDate(e.target.value)} - /> - - - - )} */} - - - + 일정 보기 diff --git a/src/__mocks__/handlers.ts b/src/__mocks__/handlers.ts index 42d6d4b7..f274bf28 100644 --- a/src/__mocks__/handlers.ts +++ b/src/__mocks__/handlers.ts @@ -1,16 +1,78 @@ import { http, HttpResponse } from 'msw'; import { events } from '../__mocks__/response/events.json' assert { type: 'json' }; -import { Event } from '../types'; +import { Event, EventForm } from '../types'; +import { defaultMockEvents } from './mockData'; + +// 테스트용 에러 핸들러 생성 헬퍼 함수 +export const createErrorHandler = (method: string, endpoint: string, statusCode: number = 500) => { + return http[method as keyof typeof http](endpoint, () => { + return HttpResponse.json({ error: 'Server Error' }, { status: statusCode }); + }); +}; // ! HARD // ! 각 응답에 대한 MSW 핸들러를 작성해주세요. GET 요청은 이미 작성되어 있는 events json을 활용해주세요. export const handlers = [ - http.get('/api/events', () => {}), + http.get('/api/events', () => { + return HttpResponse.json({ + events: defaultMockEvents, + }); + }), + + http.post('/api/events', async ({ request }) => { + const newEventData = (await request.json()) as EventForm; + + const newEvent: Event = { + id: crypto.randomUUID(), + ...newEventData, + }; + + return HttpResponse.json(newEvent, { + status: 201, + }); + }), + + http.put('/api/events/:id', async ({ params, request }) => { + const eventId = params.id; + const updatedEventData = (await request.json()) as EventForm; + + const existingEvent = events.find((event) => event.id === eventId); + + if (!existingEvent) { + return HttpResponse.json( + { + error: '이벤트를 찾을 수 없습니다.', + }, + { + status: 404, + } + ); + } + + const updatedEvent: Event = { + ...existingEvent, + ...updatedEventData, + }; + + return HttpResponse.json(updatedEvent); + }), - http.post('/api/events', async ({ request }) => {}), + http.delete('/api/events/:id', ({ params }) => { + const eventId = params.id; + const existingEvent = events.find((event) => event.id === eventId); - http.put('/api/events/:id', async ({ params, request }) => {}), + if (!existingEvent) { + return HttpResponse.json( + { + error: '삭제할 이벤트가 없습니다.', + }, + { + status: 404, + } + ); + } - http.delete('/api/events/:id', ({ params }) => {}), + return new HttpResponse(null, { status: 204 }); + }), ]; diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index 405837ec..0a1757bb 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -1,10 +1,81 @@ -import { Event } from '../types'; +import { http, HttpResponse } from 'msw'; + +import { Event, EventForm } from '../types'; +import { defaultMockEvents } from './mockData'; // ! Hard // ! 이벤트는 생성, 수정 되면 fetch를 다시 해 상태를 업데이트 합니다. 이를 위한 제어가 필요할 것 같은데요. 어떻게 작성해야 테스트가 병렬로 돌아도 안정적이게 동작할까요? // ! 아래 이름을 사용하지 않아도 되니, 독립적이게 테스트를 구동할 수 있는 방법을 찾아보세요. 그리고 이 로직을 PR에 설명해주세요. -export const setupMockHandlerCreation = (initEvents = [] as Event[]) => {}; -export const setupMockHandlerUpdating = () => {}; +export const createMockEventHandler = (initEvents = defaultMockEvents as Event[]) => { + const testEvents = [...initEvents]; + + return { + get: () => { + return http.get('/api/events', () => { + return HttpResponse.json({ + events: testEvents, + }); + }); + }, + post: () => { + return http.post('/api/events', async ({ request }) => { + const newEventData = (await request.json()) as EventForm; + + const newEvent: Event = { + id: crypto.randomUUID(), + ...newEventData, + }; + + testEvents.push(newEvent); + + return HttpResponse.json(newEvent, { + status: 201, + }); + }); + }, + put: () => { + return http.put('/api/events/:id', async ({ params, request }) => { + const eventId = params.id; + const updatedEventData = (await request.json()) as EventForm; + const existingEventIndex = testEvents.findIndex((event) => event.id === eventId); + + if (existingEventIndex === -1) { + return HttpResponse.json( + { + error: '이벤트를 찾을 수 없습니다.', + }, + { + status: 404, + } + ); + } + + testEvents[existingEventIndex] = { ...testEvents[existingEventIndex], ...updatedEventData }; + + return HttpResponse.json(testEvents[existingEventIndex]); + }); + }, + delete: () => { + return http.delete('/api/events/:id', ({ params }) => { + const eventId = params.id; + const existingEventIndex = testEvents.findIndex((event) => event.id === eventId); + + if (existingEventIndex === -1) { + return HttpResponse.json( + { + error: '삭제할 이벤트가 없습니다.', + }, + { + status: 404, + } + ); + } + + testEvents.splice(existingEventIndex, 1); -export const setupMockHandlerDeletion = () => {}; + return new HttpResponse(null, { status: 204 }); + }); + }, + }; +}; diff --git a/src/__mocks__/mockData.ts b/src/__mocks__/mockData.ts new file mode 100644 index 00000000..600ea3dc --- /dev/null +++ b/src/__mocks__/mockData.ts @@ -0,0 +1,16 @@ +import { Event } from '../types'; + +export const defaultMockEvent: Event = { + id: '1', + title: '모각코', + date: '2025-08-21', + startTime: '10:00', + endTime: '11:00', + description: '모각코 스터디', + location: '카페', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, +}; + +export const defaultMockEvents: Event[] = [defaultMockEvent]; diff --git a/src/__mocks__/response/realEvents.json b/src/__mocks__/response/realEvents.json index 5ab618a0..0ee14f7c 100644 --- a/src/__mocks__/response/realEvents.json +++ b/src/__mocks__/response/realEvents.json @@ -1,64 +1,16 @@ { "events": [ { - "id": "2b7545a6-ebee-426c-b906-2329bc8d62bd", - "title": "팀 회의", - "date": "2025-08-20", - "startTime": "10:00", - "endTime": "11:00", - "description": "주간 팀 미팅", - "location": "회의실 A", + "id": "bf502edf-bb1d-44b0-932e-bd349a7e8508", + "title": "123", + "date": "2025-08-17", + "startTime": "22:58", + "endTime": "23:59", + "description": "11", + "location": "11", "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 + "repeat": { "type": "none", "interval": 1 }, + "notificationTime": 10 } ] } diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..efd54f4d 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -1,24 +1,124 @@ import { act, renderHook } from '@testing-library/react'; +import { expect } from 'vitest'; import { useCalendarView } from '../../hooks/useCalendarView.ts'; -import { assertDate } from '../utils.ts'; describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + expect(result.current.view).toBe('month'); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); + it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + expect(result.current.currentDate.getFullYear()).toBe(new Date().getFullYear()); + expect(result.current.currentDate.getMonth()).toBe(new Date().getMonth()); + expect(result.current.currentDate.getDay()).toBe(new Date().getDay()); + }); + + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-10-01')); + }); + + expect(result.current.holidays).toEqual({ + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-03': '개천절', + '2025-10-09': '한글날', + }); + }); +}); + +it("view를 'week'으로 변경 시 view 상태가 'week'가 된다 된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + expect(result.current.view).toBe('week'); }); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); +it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + act(() => { + result.current.setCurrentDate(new Date('2025-10-01')); + }); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + act(() => { + result.current.setView('week'); + }); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + act(() => { + result.current.navigate('next'); + }); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); + expect(result.current.currentDate).toEqual(new Date('2025-10-08')); +}); + +it("주간 뷰에서 이전으로 navigate시 7일 전 '2025-09-24' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-10-01')); + }); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.navigate('prev'); + }); + + expect(result.current.currentDate).toEqual(new Date('2025-09-24')); +}); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); +it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-10-01')); + }); + + 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.setCurrentDate(new Date('2025-10-01')); + }); + + act(() => { + result.current.navigate('prev'); + }); + + expect(result.current.currentDate).toEqual(new Date('2025-09-01')); +}); + +it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-03-01')); + }); + + console.log('result.current.holidays', result.current.holidays); + + 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..00686b1e 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -1,14 +1,318 @@ import { act, renderHook } from '@testing-library/react'; +import { expect } from 'vitest'; import { useSearch } from '../../hooks/useSearch.ts'; import { Event } from '../../types.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +it('검색어가 비어있을 때 현재 주간 view 범위의 모든 이벤트를 반환해야 한다', () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-08-20', + }, + { + id: '2', + title: '이벤트 2', + date: '2025-08-20', + }, + { + id: '3', + title: '이벤트 3', + date: '2025-08-25', + }, + ] as Event[]; + const currentDate = new Date('2025-08-20'); -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); + const { result } = renderHook(() => useSearch(events, currentDate, 'week')); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); + expect(result.current.filteredEvents).toEqual([ + { id: '1', title: '이벤트 1', date: '2025-08-20' }, + { id: '2', title: '이벤트 2', date: '2025-08-20' }, + ]); +}); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); +it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-08-20', + startTime: '08:00', + endTime: '10:00', + description: '첫 번째 이벤트', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-08-20', + startTime: '11:00', + endTime: '13:00', + description: '두 번째 이벤트', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-08-25', + startTime: '12:00', + endTime: '14:00', + description: '세 번째 이벤트', + location: '회의실 C', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + ]; + const currentDate = new Date('2025-08-20'); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); + const { result } = renderHook(() => useSearch(events, currentDate, 'week')); + + act(() => { + result.current.setSearchTerm('이벤트 1'); + }); + + expect(result.current.filteredEvents).toEqual([ + { + id: '1', + title: '이벤트 1', + date: '2025-08-20', + startTime: '08:00', + endTime: '10:00', + description: '첫 번째 이벤트', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]); +}); + +it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-08-20', + startTime: '08:00', + endTime: '10:00', + description: '첫 번째 이벤트', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-08-20', + startTime: '11:00', + endTime: '13:00', + description: '두 번째 이벤트', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-08-25', + startTime: '12:00', + endTime: '14:00', + description: '세 번째 이벤트', + location: '회의실 C', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + ]; + const currentDate = new Date('2025-08-20'); + + const { result } = renderHook(() => useSearch(events, currentDate, 'week')); + + act(() => { + result.current.setSearchTerm('회의실 A'); + }); + + expect(result.current.filteredEvents).toEqual([ + { + id: '1', + title: '이벤트 1', + date: '2025-08-20', + startTime: '08:00', + endTime: '10:00', + description: '첫 번째 이벤트', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]); +}); + +it('현재 월간에 해당하는 이벤트만 반환해야 한다', () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-08-20', + startTime: '08:00', + endTime: '10:00', + description: '첫 번째 이벤트', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-08-20', + startTime: '11:00', + endTime: '13:00', + description: '두 번째 이벤트', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-09-25', + startTime: '12:00', + endTime: '14:00', + description: '세 번째 이벤트', + location: '회의실 C', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + ]; + const currentDate = new Date('2025-08-20'); + + const { result } = renderHook(() => useSearch(events, currentDate, 'month')); + + act(() => { + result.current.setSearchTerm('이벤트'); + }); + + console.log('result.current.filteredEvents', result.current.filteredEvents); + + expect(result.current.filteredEvents).toEqual([ + { + id: '1', + title: '이벤트 1', + date: '2025-08-20', + startTime: '08:00', + endTime: '10:00', + description: '첫 번째 이벤트', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-08-20', + startTime: '11:00', + endTime: '13:00', + description: '두 번째 이벤트', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]); +}); + +it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-08-20', + startTime: '08:00', + endTime: '10:00', + description: '회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-08-20', + startTime: '11:00', + endTime: '13:00', + description: '점심', + location: '식당', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-09-25', + startTime: '12:00', + endTime: '14:00', + description: '세 번째 이벤트', + location: '회의실 C', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + ]; + const currentDate = new Date('2025-08-20'); + + const { result } = renderHook(() => useSearch(events, currentDate, 'month')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.filteredEvents).toEqual([ + { + id: '1', + title: '이벤트 1', + date: '2025-08-20', + startTime: '08:00', + endTime: '10:00', + description: '회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]); + + act(() => { + result.current.setSearchTerm('점심'); + }); + + expect(result.current.filteredEvents).toEqual([ + { + id: '2', + title: '이벤트 2', + date: '2025-08-20', + startTime: '11:00', + endTime: '13:00', + description: '점심', + location: '식당', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..c0fe3aaa 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -1,14 +1,10 @@ -import { act, renderHook } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; - -import { - setupMockHandlerCreation, - setupMockHandlerDeletion, - setupMockHandlerUpdating, -} from '../../__mocks__/handlersUtils.ts'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { expect } from 'vitest'; + +import { createErrorHandler } from '../../__mocks__/handlers.ts'; +import { createMockEventHandler } from '../../__mocks__/handlersUtils.ts'; import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; -import { Event } from '../../types.ts'; const enqueueSnackbarFn = vi.fn(); @@ -22,16 +18,201 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +it('GET /api/events 호출하여 이벤트 목록을 불러오고, 로딩 완료시 스낵바에 "일정 로딩 완료!" 메시지를 띄운다', async () => { + const { result } = renderHook(() => useEventOperations(false)); + + expect(result.current.events).toEqual([]); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); + + expect(result.current.events[0]).toEqual( + expect.objectContaining({ + id: '1', + title: '모각코', + date: '2025-08-21', + startTime: '10:00', + endTime: '11:00', + }) + ); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { + variant: 'info', + }); +}); + +it('POST /api/events로 새 이벤트 생성 후, GET 재호출하여 목록을 업데이트하고 "일정이 추가되었습니다." 스낵바를 표시한다', async () => { + const newEventData = { + title: '새로운 회의', + date: '2025-08-22', + startTime: '14:00', + endTime: '15:00', + description: '새로운 팀 미팅', + location: '회의실 C', + category: '업무', + repeat: { type: 'none' as const, interval: 1 }, + notificationTime: 5, + }; + + const mockHandler = createMockEventHandler(); + server.use(mockHandler.get(), mockHandler.post()); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); + + enqueueSnackbarFn.mockClear(); + + await act(async () => { + await result.current.saveEvent(newEventData); + }); + + expect(result.current.events).toHaveLength(2); + + const addedEvent = result.current.events.find((e) => e.title === '새로운 회의'); + expect(addedEvent).toEqual(expect.objectContaining(newEventData)); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', { + variant: 'success', + }); +}); + +it('PUT /api/events/:id로 기존 이벤트 수정 후, GET 재호출하여 목록을 업데이트하고 "일정이 수정되었습니다." 스낵바를 표시한다', async () => { + const updatedEventData = { + id: '1', + title: '수정된 모각코', // 타이틀 변경 + date: '2025-08-21', + startTime: '10:00', + endTime: '12:00', // 11:00 → 12:00로 변경 + description: '수정된 모각코 스터디', + location: '도서관', // 카페 → 도서관으로 변경 + category: '개인', + repeat: { type: 'none' as const, interval: 1 }, + notificationTime: 10, + }; + + const mockHandler = createMockEventHandler(); + server.use(mockHandler.get(), mockHandler.put()); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + const { result } = renderHook(() => useEventOperations(true)); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); + + enqueueSnackbarFn.mockClear(); + + await act(async () => { + await result.current.saveEvent(updatedEventData); + }); + + expect(result.current.events).toHaveLength(1); + + const updatedEvent = result.current.events[0]; + expect(updatedEvent.title).toBe('수정된 모각코'); + expect(updatedEvent.endTime).toBe('12:00'); + expect(updatedEvent.location).toBe('도서관'); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', { + variant: 'success', + }); +}); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); +it('DELETE /api/events/:id로 이벤트 삭제 후, GET 재호출하여 목록을 업데이트하고 "일정이 삭제되었습니다." 스낵바를 표시한다', async () => { + const mockHandler = createMockEventHandler(); + server.use(mockHandler.get(), mockHandler.delete()); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + enqueueSnackbarFn.mockClear(); + + await act(async () => { + await result.current.deleteEvent('1'); + }); + + expect(result.current.events).toHaveLength(0); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { + variant: 'info', + }); +}); + +it('GET /api/events 요청 실패 시, "이벤트 로딩 실패" 에러 스낵바를 표시한다', async () => { + server.use(createErrorHandler('get', '/api/events', 500)); + + const { result } = renderHook(() => useEventOperations(false)); + + expect(result.current.events).toEqual([]); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { + variant: 'error', + }); + }); +}); + +it('PUT /api/events/:id 요청이 404로 실패 시, "일정 저장 실패" 에러 스낵바를 표시한다', async () => { + const mockHandler = createMockEventHandler(); + server.use(mockHandler.get(), createErrorHandler('put', '/api/events/:id', 404)); + + const { result } = renderHook(() => useEventOperations(true)); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); + + enqueueSnackbarFn.mockClear(); + + const nonExistentEventData = { + id: '999', // 존재하지 않는 ID + title: '존재하지 않는 이벤트', + date: '2025-08-21', + startTime: '10:00', + endTime: '11:00', + description: '테스트', + location: '테스트', + category: '업무', + repeat: { type: 'none' as const, interval: 1 }, + notificationTime: 10, + }; + + await act(async () => { + await result.current.saveEvent(nonExistentEventData); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { + variant: 'error', + }); + }); +}); + +it('DELETE /api/events/:id 요청이 네트워크 오류로 실패 시, "일정 삭제 실패" 에러 스낵바를 표시한다', async () => { + const mockHandler = createMockEventHandler(); + server.use(mockHandler.get(), createErrorHandler('delete', '/api/events/:id', 500)); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); + + enqueueSnackbarFn.mockClear(); + + 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..923b4478 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -1,14 +1,87 @@ import { act, renderHook } from '@testing-library/react'; +import { expect } from 'vitest'; +import { defaultMockEvents } from '../../__mocks__/mockData.ts'; import { useNotifications } from '../../hooks/useNotifications.ts'; import { Event } from '../../types.ts'; -import { formatDate } from '../../utils/dateUtils.ts'; -import { parseHM } from '../utils.ts'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +it('이벤트 데이터가 빈 배열일 때 알림 배열과 알람된 이벤트 배열이 비어있어야 한다.', () => { + const events: Event[] = []; -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); + const { result } = renderHook(() => useNotifications(events)); -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); + expect(result.current.notifications).toEqual([]); + expect(result.current.notifiedEvents).toEqual([]); +}); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); +it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-08-21T09:55:00')); + + const events: Event[] = defaultMockEvents; + + const { result } = renderHook(() => useNotifications(events)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifications[0]).toEqual({ + id: '1', + message: '10분 후 모각코 일정이 시작됩니다.', + }); + + expect(result.current.notifiedEvents).toEqual(['1']); + + vi.useRealTimers(); +}); + +it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-08-21T09:55:00')); + + const events: Event[] = defaultMockEvents; + + const { result } = renderHook(() => useNotifications(events)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + + act(() => { + result.current.removeNotification(0); + }); + + expect(result.current.notifications).toHaveLength(0); + expect(result.current.notifications).toEqual([]); + + vi.useRealTimers(); +}); + +it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-08-21T09:55:00')); + + const events: Event[] = defaultMockEvents; + + const { result } = renderHook(() => useNotifications(events)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toEqual(['1']); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toEqual(['1']); + + vi.useRealTimers(); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 15901d4e..cbcab123 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,49 +1,546 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act } from '@testing-library/react'; -import { UserEvent, userEvent } from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; +import { render, screen, within, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; -import { ReactElement } from 'react'; +import { createMockEventHandler } from '../__mocks__/handlersUtils.ts'; import App from '../App'; import { server } from '../setupTests'; import { Event } from '../types'; +const renderApp = () => { + const theme = createTheme(); + return render( + + + + + + + ); +}; + describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + + const newEvent: Event = { + id: 'new-test-event', + title: '새로운 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '중요한 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const mockHandler = createMockEventHandler([]); + server.use(mockHandler.get(), mockHandler.post()); + + const user = userEvent.setup(); + renderApp(); + + // 필수 필드만 입력 + await user.type(await screen.findByLabelText('제목'), newEvent.title); + await user.type(await screen.findByLabelText('날짜'), newEvent.date); + await user.type(await screen.findByLabelText('시작 시간'), newEvent.startTime); + await user.type(await screen.findByLabelText('종료 시간'), newEvent.endTime); + await user.type(await screen.findByLabelText('설명'), newEvent.description); + await user.type(await screen.findByLabelText('위치'), newEvent.location); + await user.type(await screen.findByLabelText('카테고리'), newEvent.category); + + const eventSubmitButton = screen.getByTestId('event-submit-button'); + await user.click(eventSubmitButton); + + expect(await screen.findByText('일정이 추가되었습니다.')).toBeInTheDocument(); + + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText(newEvent.title)).toBeInTheDocument(); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + const existingEvent: Event = { + id: 'test-event-1', + title: '기존 회의', + date: '2025-08-21', + startTime: '10:00', + endTime: '11:00', + description: '기존 설명', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const updatedEvent: Event = { + ...existingEvent, + title: '수정된 회의', + description: '수정된 설명', + location: '회의실 C', + }; + + // 기존 이벤트가 있는 상태로 시작 + const mockHandler = createMockEventHandler([existingEvent]); + server.use(mockHandler.get(), mockHandler.put()); + + const user = userEvent.setup(); + renderApp(); + + // 기존 이벤트가 표시되는지 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText(existingEvent.title)).toBeInTheDocument(); + + // 수정 버튼 클릭 + const editButton = await screen.findByLabelText('Edit event'); + await user.click(editButton); + + // 폼이 기존 값으로 채워졌는지 확인 + const titleField = await screen.findByLabelText('제목'); + expect(titleField).toHaveValue(existingEvent.title); + + // 값 수정 + await user.clear(titleField); + await user.type(titleField, updatedEvent.title); + + const descriptionField = await screen.findByLabelText('설명'); + await user.clear(descriptionField); + await user.type(descriptionField, updatedEvent.description); + + const locationField = await screen.findByLabelText('위치'); + await user.clear(locationField); + await user.type(locationField, updatedEvent.location); + + // 수정 버튼 클릭 + const submitButton = screen.getByTestId('event-submit-button'); + expect(submitButton).toHaveTextContent('일정 수정'); + await user.click(submitButton); + + // 수정 완료 메시지 확인 + expect(await screen.findByText('일정이 수정되었습니다.')).toBeInTheDocument(); + + // 수정된 내용이 목록에 반영되었는지 확인 + expect(await within(eventList).findByText(updatedEvent.title)).toBeInTheDocument(); + expect(await within(eventList).findByText(updatedEvent.description)).toBeInTheDocument(); + expect(await within(eventList).findByText(updatedEvent.location)).toBeInTheDocument(); + }); + + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + const existingEvent: Event = { + id: 'test-event-1', + title: '삭제될 회의', + date: '2025-08-21', + startTime: '10:00', + endTime: '11:00', + description: '삭제될 설명', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + // 기존 이벤트가 있는 상태로 시작 + const mockHandler = createMockEventHandler([existingEvent]); + server.use(mockHandler.get(), mockHandler.delete()); + + const user = userEvent.setup(); + renderApp(); + + // 기존 이벤트가 표시되는지 확인 + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText(existingEvent.title)).toBeInTheDocument(); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + // 삭제 버튼 클릭 + const deleteButton = await screen.findByLabelText('Delete event'); + await user.click(deleteButton); + + // 삭제 완료 메시지 확인 + expect(await screen.findByText('일정이 삭제되었습니다.')).toBeInTheDocument(); + + // 이벤트가 목록에서 사라졌는지 확인 + expect(within(eventList).queryByText(existingEvent.title)).not.toBeInTheDocument(); + expect(await within(eventList).findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + const mockHandler = createMockEventHandler([]); + server.use(mockHandler.get()); + + renderApp(); + + const monthView = await screen.findByTestId('month-view'); + expect(monthView).toBeInTheDocument(); + + const calendarCells = monthView.querySelectorAll('td'); + calendarCells.forEach((cell) => { + const eventBoxes = cell.querySelectorAll('.MuiBox-root'); + eventBoxes.forEach((box) => { + expect(box.textContent).not.toMatch(/회의|미팅|약속/); + }); + }); + }); + + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + const testEvent: Event = { + id: 'week-test-event', + title: '주간 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '주간 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const mockHandler = createMockEventHandler([testEvent]); + server.use(mockHandler.get()); + + renderApp(); + + const monthView = await screen.findByTestId('month-view'); + expect(monthView).toBeInTheDocument(); + + expect(await within(monthView).findByText(testEvent.title)).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + const mockHandler = createMockEventHandler([]); + server.use(mockHandler.get()); + + const user = userEvent.setup(); + renderApp(); + + const viewSelect = await screen.findByLabelText('뷰 타입 선택'); + await user.click(viewSelect); + + await waitFor(async () => { + const monthOption = screen.getByText('Month'); + expect(monthOption).toBeInTheDocument(); + }); + + const monthOption = screen.getByText('Month'); + await user.click(monthOption); + + const monthView = await screen.findByTestId('month-view'); + expect(monthView).toBeInTheDocument(); + + const calendarCells = monthView.querySelectorAll('td'); + calendarCells.forEach((cell) => { + const eventBoxes = cell.querySelectorAll('.MuiBox-root'); + eventBoxes.forEach((box) => { + expect(box.textContent).not.toMatch(/회의|미팅|약속/); + }); + }); + }); + + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + const testEvent: Event = { + id: 'month-test-event', + title: '월간 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '월간 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const mockHandler = createMockEventHandler([testEvent]); + server.use(mockHandler.get()); + + const user = userEvent.setup(); + renderApp(); + + const viewSelect = await screen.findByLabelText('뷰 타입 선택'); + await user.click(viewSelect); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + await waitFor(async () => { + const monthOption = screen.getByText('Month'); + expect(monthOption).toBeInTheDocument(); + }); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + const monthOption = screen.getByText('Month'); + await user.click(monthOption); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + const monthView = await screen.findByTestId('month-view'); + expect(monthView).toBeInTheDocument(); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + expect(await within(monthView).findByText(testEvent.title)).toBeInTheDocument(); + }); + + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + const mockHandler = createMockEventHandler([]); + server.use(mockHandler.get()); + + const user = userEvent.setup(); + renderApp(); + + const viewSelect = await screen.findByLabelText('뷰 타입 선택'); + await user.click(viewSelect); + + await waitFor(async () => { + const monthOption = screen.getByText('Month'); + expect(monthOption).toBeInTheDocument(); + }); + + const monthOption = screen.getByText('Month'); + await user.click(monthOption); + + const prevButton = await screen.findByLabelText('Previous'); + for (let i = 0; i < 7; i++) { + await user.click(prevButton); + } + + const monthView = await screen.findByTestId('month-view'); + expect(monthView).toBeInTheDocument(); + + expect(await within(monthView).findByText('신정')).toBeInTheDocument(); + + expect(await within(monthView).findByText('1')).toBeInTheDocument(); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + const mockHandler = createMockEventHandler([]); + server.use(mockHandler.get()); + + const user = userEvent.setup(); + renderApp(); + + const searchField = await screen.findByLabelText('일정 검색'); + await user.type(searchField, '존재하지 않는 일정'); + + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + const testEvents: Event[] = [ + { + id: 'search-test-event-1', + title: '팀 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '주간 팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: 'search-test-event-2', + title: '개인 일정', + date: '2025-08-21', + startTime: '16:00', + endTime: '17:00', + description: '개인적인 일정', + location: '카페', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + const mockHandler = createMockEventHandler(testEvents); + server.use(mockHandler.get()); + + const user = userEvent.setup(); + renderApp(); + + const searchField = await screen.findByLabelText('일정 검색'); + await user.type(searchField, '팀 회의'); + + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText('팀 회의')).toBeInTheDocument(); + expect(within(eventList).queryByText('개인 일정')).not.toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + const testEvents: Event[] = [ + { + id: 'search-test-event-1', + title: '팀 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '주간 팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: 'search-test-event-2', + title: '개인 일정', + date: '2025-08-21', + startTime: '16:00', + endTime: '17:00', + description: '개인적인 일정', + location: '카페', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + const mockHandler = createMockEventHandler(testEvents); + server.use(mockHandler.get()); + + const user = userEvent.setup(); + renderApp(); + + const searchField = await screen.findByLabelText('일정 검색'); + await user.type(searchField, '팀 회의'); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + const eventList = screen.getByTestId('event-list'); + expect(await within(eventList).findByText('팀 회의')).toBeInTheDocument(); + expect(within(eventList).queryByText('개인 일정')).not.toBeInTheDocument(); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + await user.clear(searchField); + + expect(await within(eventList).findByText('팀 회의')).toBeInTheDocument(); + expect(await within(eventList).findByText('개인 일정')).toBeInTheDocument(); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + const existingEvent: Event = { + id: 'overlap-test-event', + title: '기존 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '기존 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const mockHandler = createMockEventHandler([existingEvent]); + server.use(mockHandler.get(), mockHandler.post()); + + const user = userEvent.setup(); + renderApp(); + + await user.type(await screen.findByLabelText('제목'), '새로운 회의'); + await user.type(await screen.findByLabelText('날짜'), '2025-08-21'); + await user.type(await screen.findByLabelText('시작 시간'), '14:30'); + await user.type(await screen.findByLabelText('종료 시간'), '15:30'); + await user.type(await screen.findByLabelText('설명'), '겹치는 회의'); + await user.type(await screen.findByLabelText('위치'), '회의실 B'); + await user.type(await screen.findByLabelText('카테고리'), '업무'); + + const eventSubmitButton = screen.getByTestId('event-submit-button'); + await user.click(eventSubmitButton); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + expect(await screen.findByText(/다음 일정과 겹칩니다/)).toBeInTheDocument(); + expect(await screen.findByText(/기존 회의.*2025-08-21 14:00-15:00/)).toBeInTheDocument(); + + expect(await screen.findByText('취소')).toBeInTheDocument(); + expect(await screen.findByText('계속 진행')).toBeInTheDocument(); + }); + + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + const existingEvents: Event[] = [ + { + id: 'overlap-test-event-1', + title: '첫 번째 회의', + date: '2025-08-21', + startTime: '14:00', + endTime: '15:00', + description: '첫 번째 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: 'overlap-test-event-2', + title: '두 번째 회의', + date: '2025-08-21', + startTime: '16:00', + endTime: '17:00', + description: '두 번째 회의입니다', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + const mockHandler = createMockEventHandler(existingEvents); + server.use(mockHandler.get(), mockHandler.put()); + + const user = userEvent.setup(); + renderApp(); + + const editButtons = await screen.findAllByLabelText('Edit event'); + await user.click(editButtons[0]); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + const startTimeField = await screen.findByLabelText('시작 시간'); + const endTimeField = await screen.findByLabelText('종료 시간'); + + await user.clear(startTimeField); + await user.type(startTimeField, '16:30'); + await user.clear(endTimeField); + await user.type(endTimeField, '17:30'); + + const submitButton = screen.getByTestId('event-submit-button'); + await user.click(submitButton); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + expect(await screen.findByText(/다음 일정과 겹칩니다/)).toBeInTheDocument(); + expect(await screen.findByText(/두 번째 회의.*2025-08-21 16:00-17:00/)).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + const now = new Date(); + const eventTime = new Date(now.getTime() + 10 * 60 * 1000); + + const testEvent: Event = { + id: 'notification-test-event', + title: '알림 테스트 회의', + date: eventTime.toISOString().split('T')[0], + startTime: eventTime.toTimeString().slice(0, 5), + endTime: new Date(eventTime.getTime() + 60 * 60 * 1000).toTimeString().slice(0, 5), + description: '알림 테스트용 회의', + location: '테스트 룸', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const mockHandler = createMockEventHandler([testEvent]); + server.use(mockHandler.get()); + + renderApp(); + + const eventList = screen.getByTestId('event-list'); + + await waitFor( + () => { + const notificationIcon = within(eventList).queryByTestId('notification-icon'); + const alertMessage = screen.queryByText(/10분 전/); + + expect(notificationIcon || alertMessage).toBeTruthy(); + }, + { timeout: 3000 } + ); + + expect(await within(eventList).findByText('알림 테스트 회의')).toBeInTheDocument(); +}); diff --git a/src/__tests__/unit/EventForm.spec.tsx b/src/__tests__/unit/EventForm.spec.tsx new file mode 100644 index 00000000..37ca94c1 --- /dev/null +++ b/src/__tests__/unit/EventForm.spec.tsx @@ -0,0 +1,173 @@ +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { vi } from 'vitest'; + +import EventForm from '../../components/EventForm'; +import { Event } from '../../types'; + +describe('EventForm', () => { + const mockProps = { + title: '', + setTitle: vi.fn(), + date: '', + setDate: vi.fn(), + startTime: '', + endTime: '', + description: '', + setDescription: vi.fn(), + location: '', + setLocation: vi.fn(), + category: '업무', + setCategory: vi.fn(), + isRepeating: false, + setIsRepeating: vi.fn(), + notificationTime: 10, + setNotificationTime: vi.fn(), + startTimeError: null, + endTimeError: null, + editingEvent: null, + handleStartTimeChange: vi.fn(), + handleEndTimeChange: vi.fn(), + onSubmit: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('새 일정 추가 모드에서 올바른 제목이 표시된다', () => { + render(); + + expect(screen.getByRole('heading', { name: '일정 추가' })).toBeInTheDocument(); + }); + + it('일정 수정 모드에서 올바른 제목이 표시된다', () => { + const editingEvent: Event = { + id: 'test-id', + title: '기존 일정', + date: '2025-08-21', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + render(); + + expect(screen.getByRole('heading', { name: '일정 수정' })).toBeInTheDocument(); + }); + + it('제목 입력 시 setTitle이 호출된다', async () => { + const user = userEvent.setup(); + render(); + + const titleInput = screen.getByLabelText('제목'); + await user.type(titleInput, '회의'); + + expect(mockProps.setTitle).toHaveBeenCalled(); + }); + + it('날짜 입력 시 setDate가 호출된다', async () => { + const user = userEvent.setup(); + render(); + + const dateInput = screen.getByLabelText('날짜'); + await user.type(dateInput, '2025-08-21'); + + expect(mockProps.setDate).toHaveBeenCalled(); + }); + + it('시작 시간 변경 시 handleStartTimeChange가 호출된다', async () => { + const user = userEvent.setup(); + render(); + + const startTimeInput = screen.getByLabelText('시작 시간'); + await user.type(startTimeInput, '14:30'); + + expect(mockProps.handleStartTimeChange).toHaveBeenCalled(); + }); + + it('종료 시간 변경 시 handleEndTimeChange가 호출된다', async () => { + const user = userEvent.setup(); + render(); + + const endTimeInput = screen.getByLabelText('종료 시간'); + await user.type(endTimeInput, '15:30'); + + expect(mockProps.handleEndTimeChange).toHaveBeenCalled(); + }); + + it('설명 입력 시 setDescription이 호출된다', async () => { + const user = userEvent.setup(); + render(); + + const descriptionInput = screen.getByLabelText('설명'); + await user.type(descriptionInput, '회의'); + + expect(mockProps.setDescription).toHaveBeenCalled(); + }); + + it('위치 입력 시 setLocation이 호출된다', async () => { + const user = userEvent.setup(); + render(); + + const locationInput = screen.getByLabelText('위치'); + await user.type(locationInput, '회의실'); + + expect(mockProps.setLocation).toHaveBeenCalled(); + }); + + it('카테고리 Select 컴포넌트가 렌더링된다', () => { + render(); + + const categorySelect = screen.getByDisplayValue('업무'); + expect(categorySelect).toBeInTheDocument(); + }); + + it('반복 일정 체크박스 변경 시 setIsRepeating이 호출된다', async () => { + const user = userEvent.setup(); + render(); + + const repeatCheckbox = screen.getByRole('checkbox', { name: '반복 일정' }); + await user.click(repeatCheckbox); + + expect(mockProps.setIsRepeating).toHaveBeenCalledWith(true); + }); + + it('시작 시간 에러가 있을 때 에러 상태가 표시된다', () => { + const propsWithError = { + ...mockProps, + startTimeError: '시간을 확인해주세요', + }; + + render(); + + const startTimeInput = screen.getByLabelText('시작 시간'); + expect(startTimeInput).toHaveAttribute('aria-invalid', 'true'); + }); + + it('종료 시간 에러가 있을 때 에러 상태가 표시된다', () => { + const propsWithError = { + ...mockProps, + endTimeError: '시간을 확인해주세요', + }; + + render(); + + const endTimeInput = screen.getByLabelText('종료 시간'); + expect(endTimeInput).toHaveAttribute('aria-invalid', 'true'); + }); + + it('폼 제출 버튼 클릭 시 onSubmit이 호출된다', async () => { + const user = userEvent.setup(); + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + await user.click(submitButton); + + expect(mockProps.onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..7e366d2a 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, @@ -12,105 +14,425 @@ import { } from '../../utils/dateUtils'; describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); + it('1월은 31일 수를 반환한다', () => { + const daysInMonth = getDaysInMonth(2025, 1); - it('4월은 30일 일수를 반환한다', () => {}); + expect(daysInMonth).toBe(31); + }); - it('윤년의 2월에 대해 29일을 반환한다', () => {}); + it('4월은 30일 일수를 반환한다', () => { + const daysInMonth = getDaysInMonth(2025, 4); - it('평년의 2월에 대해 28일을 반환한다', () => {}); + expect(daysInMonth).toBe(30); + }); - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); -}); + it('윤년의 2월에 대해 29일을 반환한다', () => { + const daysInMonth = getDaysInMonth(2024, 2); -describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + expect(daysInMonth).toBe(29); + }); - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + it('평년의 2월에 대해 28일을 반환한다', () => { + const daysInMonth = getDaysInMonth(2025, 2); - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + expect(daysInMonth).toBe(28); + }); - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); + it('유효하지 않은 월에 대해 0을 반환한다.', () => { + const daysInMonth = getDaysInMonth(2025, 0); - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); - - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); + expect(daysInMonth).toBe(0); + }); +}); - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); +describe('getWeekDates', () => { + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const weekDates = getWeekDates(new Date('2025-08-20')); + + const expected = [ + new Date('2025-08-17').toDateString(), + new Date('2025-08-18').toDateString(), + new Date('2025-08-19').toDateString(), + new Date('2025-08-20').toDateString(), + new Date('2025-08-21').toDateString(), + new Date('2025-08-22').toDateString(), + new Date('2025-08-23').toDateString(), + ]; + + expect(weekDates.map((date) => date.toDateString())).toEqual(expected); + }); + + it('월요일 날짜에 대해 해당 주의 7일을 반환한다', () => { + const weekDates = getWeekDates(new Date('2025-08-18')); + + const expected = [ + new Date('2025-08-17').toDateString(), + new Date('2025-08-18').toDateString(), + new Date('2025-08-19').toDateString(), + new Date('2025-08-20').toDateString(), + new Date('2025-08-21').toDateString(), + new Date('2025-08-22').toDateString(), + new Date('2025-08-23').toDateString(), + ]; + + expect(weekDates.map((date) => date.toDateString())).toEqual(expected); + }); + + it('주 시작일(일요일)에 대해 해당 주의 모든 날짜를 \n' + ' 반환한다', () => { + const weekDates = getWeekDates(new Date('2025-08-17')); + + const expected = [ + new Date('2025-08-17').toDateString(), + new Date('2025-08-18').toDateString(), + new Date('2025-08-19').toDateString(), + new Date('2025-08-20').toDateString(), + new Date('2025-08-21').toDateString(), + new Date('2025-08-22').toDateString(), + new Date('2025-08-23').toDateString(), + ]; + + expect(weekDates.map((date) => date.toDateString())).toEqual(expected); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + const weekDates = getWeekDates(new Date('2025-12-31')); + + const expected = [ + new Date('2025-12-28').toDateString(), + new Date('2025-12-29').toDateString(), + new Date('2025-12-30').toDateString(), + new Date('2025-12-31').toDateString(), + new Date('2026-01-01').toDateString(), + new Date('2026-01-02').toDateString(), + new Date('2026-01-03').toDateString(), + ]; + + expect(weekDates.map((date) => date.toDateString())).toEqual(expected); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + const weekDates = getWeekDates(new Date('2025-01-01')); + + const expected = [ + new Date('2024-12-29').toDateString(), + new Date('2024-12-30').toDateString(), + new Date('2024-12-31').toDateString(), + new Date('2025-01-01').toDateString(), + new Date('2025-01-02').toDateString(), + new Date('2025-01-03').toDateString(), + new Date('2025-01-04').toDateString(), + ]; + + expect(weekDates.map((date) => date.toDateString())).toEqual(expected); + }); + + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + const weekDates = getWeekDates(new Date('2024-02-29')); + + const expected = [ + new Date('2024-02-25').toDateString(), + new Date('2024-02-26').toDateString(), + new Date('2024-02-27').toDateString(), + new Date('2024-02-28').toDateString(), + new Date('2024-02-29').toDateString(), + new Date('2024-03-01').toDateString(), + new Date('2024-03-02').toDateString(), + ]; + + expect(weekDates.map((date) => date.toDateString())).toEqual(expected); + }); + + it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => { + const weekDates = getWeekDates(new Date('2025-08-31')); + + const expected = [ + new Date('2025-08-31').toDateString(), + new Date('2025-09-01').toDateString(), + new Date('2025-09-02').toDateString(), + new Date('2025-09-03').toDateString(), + new Date('2025-09-04').toDateString(), + new Date('2025-09-05').toDateString(), + new Date('2025-09-06').toDateString(), + ]; + + expect(weekDates.map((date) => date.toDateString())).toEqual(expected); + }); }); describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); + it('2025년 7월의 주별 날짜 배열을 반환한다', () => { + const weeksAtMonth = getWeeksAtMonth(new Date('2025-07-01')); + + const expected = [ + [null, null, 1, 2, 3, 4, 5], + [6, 7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18, 19], + [20, 21, 22, 23, 24, 25, 26], + [27, 28, 29, 30, 31, null, null], + ]; + + expect(weeksAtMonth).toEqual(expected); + }); }); describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + const events: Event[] = [ + { + id: '1', + date: '2025-08-01', + }, + { + id: '2', + date: '2025-08-21', + }, + ] as Event[]; + + const result = getEventsForDay(events, 1); + expect(result).toHaveLength(1); + expect(result[0].date).toBe('2025-08-01'); + expect(result[0].id).toBe('1'); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + const events: Event[] = [ + { + id: '1', + date: '2025-08-01', + }, + { + id: '2', + date: '2025-08-21', + }, + ] as Event[]; + + const result = getEventsForDay(events, 2); + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + const events: Event[] = [ + { + id: '1', + date: '2025-08-01', + }, + { + id: '2', + date: '2025-08-21', + }, + ] as Event[]; + + const result = getEventsForDay(events, 0); + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + const events: Event[] = [ + { + id: '1', + date: '2025-08-01', + }, + { + id: '2', + date: '2025-08-31', + }, + ] as Event[]; + + const result = getEventsForDay(events, 32); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); +}); - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); +describe('formatWeek', () => { + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + const week = formatWeek(new Date('2025-08-15')); - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); + expect(week).toBe('2025년 8월 2주'); + }); - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); -}); + it('월의 첫 주에 포함된 주의 소속 월과 주차를 반환한다', () => { + const week = formatWeek(new Date('2025-08-01')); -describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); + expect(week).toBe('2025년 7월 5주'); + }); - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 마지막 주에 포함된 주의 소속 월과 주차를 반환한다', () => { + const week = formatWeek(new Date('2025-08-31')); - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + expect(week).toBe('2025년 9월 1주'); + }); - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('연도가 바뀌는 주에 포함된 주의 소속 월과 주차를 반환한다', () => { + const week = formatWeek(new Date('2025-01-01')); - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + expect(week).toBe('2025년 1월 1주'); + }); - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('윤년 2월의 마지막 주에 포함된 주의 소속 월과 주차를 반환한다', () => { + const week = formatWeek(new Date('2024-02-29')); + + expect(week).toBe('2024년 2월 5주'); + }); + + it('평년 2월의 마지막 주에 포함된 주의 소속 월과 주차를 반환한다', () => { + const week = formatWeek(new Date('2025-02-29')); + + expect(week).toBe('2025년 2월 4주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + const month = formatMonth(new Date('2025-07-10')); + + expect(month).toBe('2025년 7월'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + const date = new Date('2025-07-10'); + const startDate = new Date('2025-07-01'); + const endDate = new Date('2025-07-31'); + + const isInRange = isDateInRange(date, startDate, endDate); + + expect(isInRange).toBe(true); + }); + + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + const date = new Date('2025-07-01'); + const startDate = new Date('2025-07-01'); + const endDate = new Date('2025-07-31'); + + const isInRange = isDateInRange(date, startDate, endDate); + + expect(isInRange).toBe(true); + }); + + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + const date = new Date('2025-07-31'); + const startDate = new Date('2025-07-01'); + const endDate = new Date('2025-07-31'); - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); + const isInRange = isDateInRange(date, startDate, endDate); - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); + expect(isInRange).toBe(true); + }); - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + const date = new Date('2025-06-30'); + const startDate = new Date('2025-07-01'); + const endDate = new Date('2025-07-31'); - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); + const isInRange = isDateInRange(date, startDate, endDate); - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + expect(isInRange).toBe(false); + }); + + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + const date = new Date('2025-08-01'); + const startDate = new Date('2025-07-01'); + const endDate = new Date('2025-07-31'); + + const isInRange = isDateInRange(date, startDate, endDate); + + expect(isInRange).toBe(false); + }); + + it('시작일이 종료일(시작일 > 종료일)보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + const date = new Date('2025-07-01'); + const startDate = new Date('2025-07-31'); + const endDate = new Date('2025-07-01'); + + const isInRange = isDateInRange(date, startDate, endDate); + + expect(isInRange).toBe(false); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + const filled = fillZero(5); + + expect(filled).toBe('05'); + }); + + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + const filled = fillZero(10); + + expect(filled).toBe('10'); + }); + + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + const filled = fillZero(3, 3); - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); + expect(filled).toBe('003'); + }); - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + const filled = fillZero(100); - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); + expect(filled).toBe('100'); + }); - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + const filled = fillZero(0); - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); + expect(filled).toBe('00'); + }); - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + const filled = fillZero(1, 5); - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); + expect(filled).toBe('00001'); + }); - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + const filled = fillZero(3.14, 5); + + expect(filled).toBe('03.14'); + }); + + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + const filled = fillZero(3); + + expect(filled).toBe('03'); + }); + + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + const filled = fillZero(123456789, 5); + + expect(filled).toBe('123456789'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + const formatted = formatDate(new Date('2025-08-10')); + + expect(formatted).toBe('2025-08-10'); + }); + + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + const formatted = formatDate(new Date('2025-08-10'), 1); + + expect(formatted).toBe('2025-08-01'); + }); + + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const formatted = formatDate(new Date('2025-01-11')); - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); + expect(formatted).toBe('2025-01-11'); + }); - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const formatted = formatDate(new Date('2025-12-01')); - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + expect(formatted).toBe('2025-12-01'); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..bf01e1d8 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -1,4 +1,6 @@ -import { Event } from '../../types'; +import { expect } from 'vitest'; + +import { Event } from '../../types.ts'; import { convertEventToDateRange, findOverlappingEvents, @@ -6,31 +8,177 @@ import { parseDateTime, } from '../../utils/eventOverlap'; describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + const parsedDate = parseDateTime('2025-07-01', '14:30'); + + expect(parsedDate).toEqual(new Date('2025-07-01T14:30:00')); + }); + + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + const parsedDate = parseDateTime('2025-13-01', '14:30'); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + expect(isNaN(parsedDate.getTime())).toBe(true); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + const parsedDate = parseDateTime('2025-13-01', '142:300'); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + expect(isNaN(parsedDate.getTime())).toBe(true); + }); + + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + const parsedDate = parseDateTime('', '14:30'); + + expect(isNaN(parsedDate.getTime())).toBe(true); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const event: Event = { + id: '1', + date: '2025-08-01', + startTime: '14:30', + endTime: '15:30', + } as Event; + + const dateRange = convertEventToDateRange(event); + + const expected = { + start: new Date(2025, 7, 1, 14, 30), + end: new Date(2025, 7, 1, 15, 30), + }; + + expect(dateRange).toEqual(expected); + }); + + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event: Event = { + id: '1', + date: '2025-8-1', + startTime: '14:30', + endTime: '15:30', + } as Event; + + const dateRange = convertEventToDateRange(event); + + expect(Number.isNaN(dateRange.start.getTime())).toBe(true); + expect(Number.isNaN(dateRange.end.getTime())).toBe(true); + }); - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event: Event = { + id: '1', + date: '2025-8-1', + startTime: '14-30', + endTime: '15-30', + } as Event; - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + const dateRange = convertEventToDateRange(event); + + expect(Number.isNaN(dateRange.start.getTime())).toBe(true); + expect(Number.isNaN(dateRange.end.getTime())).toBe(true); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + const events: Event[] = [ + { + id: '1', + date: '2025-08-01', + startTime: '14:30', + endTime: '15:30', + }, + { + id: '2', + date: '2025-08-01', + startTime: '14:30', + endTime: '15:30', + }, + ] as Event[]; + + const result = isOverlapping(events[0], events[1]); + + expect(result).toBe(true); + }); - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + const events: Event[] = [ + { + id: '1', + date: '2025-08-01', + startTime: '14:30', + endTime: '15:30', + }, + { + id: '2', + date: '2025-08-05', + startTime: '08:30', + endTime: '20:30', + }, + ] as Event[]; + + const result = isOverlapping(events[0], events[1]); + + expect(result).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const newEvent: Event = { + id: '3', + date: '2025-08-01', + startTime: '14:30', + endTime: '15:30', + } as Event; + + const events: Event[] = [ + { + id: '1', + date: '2025-08-01', + startTime: '14:30', + endTime: '15:30', + }, + { + id: '2', + date: '2025-08-05', + startTime: '08:30', + endTime: '20:30', + }, + ] as Event[]; + + const overlappingEvents = findOverlappingEvents(newEvent, events); + + expect(overlappingEvents).toEqual([events[0]]); + }); + + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent: Event = { + id: '3', + date: '2025-08-03', + startTime: '02:00', + endTime: '05:00', + } as Event; + + const events: Event[] = [ + { + id: '1', + date: '2025-08-01', + startTime: '14:30', + endTime: '15:30', + }, + { + id: '2', + date: '2025-08-05', + startTime: '08:30', + endTime: '20:30', + }, + ] as Event[]; + + const overlappingEvents = findOverlappingEvents(newEvent, events); - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + expect(overlappingEvents).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..cc28eab1 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -1,20 +1,266 @@ +import { expect } from 'vitest'; + import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '이벤트 2', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + ] as Event[]; + const currentDate = new Date('2025-08-01'); + const filteredEvents = getFilteredEvents(events, '이벤트 2', currentDate, 'week'); + expect(filteredEvents).toEqual([ + { + id: '2', + title: '이벤트 2', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + ]); + }); + + it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-20', + description: '이벤트', + location: '회사', + }, + ] as Event[]; + const currentDate = new Date('2025-07-01'); + + const filteredEvents = getFilteredEvents(events, '', currentDate, 'week'); + + expect(filteredEvents).toEqual([ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + description: '이벤트', + location: '회사', + }, + ]); + }); + + it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-20', + description: '이벤트', + location: '회사', + }, + ] as Event[]; + const currentDate = new Date('2025-07-01'); + + const filteredEvents = getFilteredEvents(events, '', currentDate, 'month'); + + expect(filteredEvents).toEqual([ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-20', + description: '이벤트', + location: '회사', + }, + ]); + }); + + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '회의', + date: '2025-07-03', + description: '회의', + location: '회사', + }, + { + id: '3', + title: '이벤트 2', + date: '2025-07-15', + description: '이벤트', + location: '회사', + }, + ] as Event[]; + const currentDate = new Date('2025-07-01'); + + const filteredEvents = getFilteredEvents(events, '이벤트', currentDate, 'week'); + + expect(filteredEvents).toEqual([ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + description: '이벤트', + location: '회사', + }, + ]); + }); + + it('검색어가 없을 때 모든 이벤트를 반환한다', () => { + const events: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '이벤트 2', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + ] as Event[]; + const currentDate = new Date('2025-08-01'); + const filteredEvents = getFilteredEvents(events, '', currentDate, 'week'); + expect(filteredEvents).toEqual([ + { + id: '1', + title: '이벤트 1', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '이벤트 2', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + ]); + }); - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const events: Event[] = [ + { + id: '1', + title: 'Event 1', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: 'event 2', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + ] as Event[]; + const currentDate = new Date('2025-08-01'); + const filteredEvents = getFilteredEvents(events, 'Event 2', currentDate, 'week'); + expect(filteredEvents).toEqual([ + { + id: '2', + title: 'event 2', + date: '2025-08-01', + description: '이벤트', + location: '회사', + }, + ]); + }); - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const events: Event[] = [ + { + id: '1', + title: '6월 마지막날', + date: '2025-06-30', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '7월 첫날', + date: '2025-07-01', + description: '이벤트', + location: '회사', + }, + { + id: '3', + title: '7월 다른주', + date: '2025-07-08', + description: '이벤트', + location: '회사', + }, + ] as Event[]; - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + const result = getFilteredEvents(events, '', new Date('2025-07-01'), 'week'); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + expect(result).toEqual([ + { + id: '1', + title: '6월 마지막날', + date: '2025-06-30', + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '7월 첫날', + date: '2025-07-01', + description: '이벤트', + location: '회사', + }, + ]); + }); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const events: Event[] = []; - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + const result = getFilteredEvents(events, '', new Date('2025-07-01'), 'week'); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + expect(result).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..93c4f82e 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,36 @@ +import { expect } from 'vitest'; + import { fetchHolidays } from '../../apis/fetchHolidays'; + describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + const date = new Date('2025-01-01'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + }); + + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + const date = new Date('2025-02-01'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({}); + }); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + const date = new Date('2025-01-01'); + const holidays = fetchHolidays(date); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + expect(holidays).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..9fe53d47 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -1,16 +1,164 @@ +import { expect } from 'vitest'; + import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + const events: Event[] = [ + { + id: '1', + title: '알림 1', + date: '2025-08-20', + startTime: '14:15', + notificationTime: 15, + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '알림 2', + date: '2025-08-20', + startTime: '16:00', + notificationTime: 10, + description: '이벤트', + location: '회사', + }, + ] as Event[]; + + const now = new Date('2025-08-20T14:00:00'); + const notifiedEvents: string[] = []; + + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([ + { + id: '1', + title: '알림 1', + date: '2025-08-20', + startTime: '14:15', + notificationTime: 15, + description: '이벤트', + location: '회사', + }, + ]); + }); + + it('이미 알림이 간 이벤트는 제외한다', () => { + const events: Event[] = [ + { + id: '1', + title: '이미 보낸 알림', + date: '2025-08-20', + startTime: '14:15', + notificationTime: 15, + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '아직 보내지 않은 알림', + date: '2025-08-20', + startTime: '14:15', + notificationTime: 10, + description: '이벤트', + location: '회사', + }, + ] as Event[]; + + const now = new Date('2025-08-20T14:05:00'); + const notifiedEvents: string[] = ['1']; + + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([ + { + id: '2', + title: '아직 보내지 않은 알림', + date: '2025-08-20', + startTime: '14:15', + notificationTime: 10, + description: '이벤트', + location: '회사', + }, + ]); + }); - it('이미 알림이 간 이벤트는 제외한다', () => {}); + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + const events: Event[] = [ + { + id: '1', + title: '알림 1', + date: '2025-08-20', + startTime: '15:15', + notificationTime: 15, + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '알림 2', + date: '2025-08-20', + startTime: '16:00', + notificationTime: 10, + description: '이벤트', + location: '회사', + }, + ] as Event[]; - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + const now = new Date('2025-08-20T14:00:00'); + const notifiedEvents: string[] = []; - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([]); + }); + + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const events: Event[] = [ + { + id: '1', + title: '알림 1', + date: '2025-08-20', + startTime: '14:15', + notificationTime: 15, + description: '이벤트', + location: '회사', + }, + { + id: '2', + title: '알림 2', + date: '2025-08-20', + startTime: '14:00', + notificationTime: 10, + description: '이벤트', + location: '회사', + }, + ] as Event[]; + + const now = new Date('2025-08-20T14:30:00'); + const notifiedEvents: string[] = []; + + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([]); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const event: Event = { + id: '1', + title: '알림 1', + date: '2025-08-20', + startTime: '14:15', + notificationTime: 15, + description: '이벤트', + location: '회사', + } as Event; + + const notificationMessage = createNotificationMessage(event); + + expect(notificationMessage).toBe('15분 후 알림 1 일정이 시작됩니다.'); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..bbbe6de2 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,71 @@ +import { expect } from 'vitest'; + import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const startTime = '14:00'; + const endTime = '13:00'; + const timeErrorMessage = getTimeErrorMessage(startTime, endTime); + + expect(timeErrorMessage).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); + + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const startTime = '14:00'; + const endTime = '14:00'; + const timeErrorMessage = getTimeErrorMessage(startTime, endTime); + + expect(timeErrorMessage).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); + + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + const startTime = '13:00'; + const endTime = '14:00'; + const timeErrorMessage = getTimeErrorMessage(startTime, endTime); + + expect(timeErrorMessage).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); + + it('시작 시간이 비어있을 때 null을 반환한다', () => { + const startTime = ''; + const endTime = '14:00'; + const timeErrorMessage = getTimeErrorMessage(startTime, endTime); - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + expect(timeErrorMessage).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('종료 시간이 비어있을 때 null을 반환한다', () => { + const startTime = '13:00'; + const endTime = ''; + const timeErrorMessage = getTimeErrorMessage(startTime, endTime); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + expect(timeErrorMessage).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + const startTime = ''; + const endTime = ''; + const timeErrorMessage = getTimeErrorMessage(startTime, endTime); - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + expect(timeErrorMessage).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); }); diff --git a/src/components/EventForm.tsx b/src/components/EventForm.tsx new file mode 100644 index 00000000..8104f65b --- /dev/null +++ b/src/components/EventForm.tsx @@ -0,0 +1,202 @@ +import { + Button, + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import React from 'react'; + +import { notificationOptions } from '../constants/notifications'; +import { Event } from '../types'; +import { getTimeErrorMessage } from '../utils/timeValidation'; + +const categories = ['업무', '개인', '가족', '기타']; + +interface EventFormProps { + title: string; + setTitle: (title: string) => void; + date: string; + setDate: (date: string) => void; + startTime: string; + endTime: string; + description: string; + setDescription: (description: string) => void; + location: string; + setLocation: (location: string) => void; + category: string; + setCategory: (category: string) => void; + isRepeating: boolean; + setIsRepeating: (isRepeating: boolean) => void; + notificationTime: number; + setNotificationTime: (time: number) => void; + startTimeError: string | null; + endTimeError: string | null; + editingEvent: Event | null; + handleStartTimeChange: (e: React.ChangeEvent) => void; + handleEndTimeChange: (e: React.ChangeEvent) => void; + onSubmit: () => void; +} + +const EventForm = ({ + title, + setTitle, + date, + setDate, + startTime, + endTime, + description, + setDescription, + location, + setLocation, + category, + setCategory, + isRepeating, + setIsRepeating, + notificationTime, + setNotificationTime, + startTimeError, + endTimeError, + editingEvent, + handleStartTimeChange, + handleEndTimeChange, + onSubmit, +}: EventFormProps) => { + return ( + + {editingEvent ? '일정 수정' : '일정 추가'} + + + 제목 + setTitle(e.target.value)} + /> + + + + 날짜 + setDate(e.target.value)} + /> + + + + + 시작 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!startTimeError} + /> + + + + 종료 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!endTimeError} + /> + + + + + + 설명 + setDescription(e.target.value)} + /> + + + + 위치 + setLocation(e.target.value)} + /> + + + + 카테고리 + + + + + setIsRepeating(e.target.checked)} /> + } + label="반복 일정" + /> + + + + 알림 설정 + + + + + + ); +}; + +export default EventForm; diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts new file mode 100644 index 00000000..145d5b5c --- /dev/null +++ b/src/constants/notifications.ts @@ -0,0 +1,7 @@ +export const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index f9ec573b..6adbe79a 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -3,6 +3,10 @@ import { useEffect, useState } from 'react'; import { Event } from '../types'; import { createNotificationMessage, getUpcomingEvents } from '../utils/notificationUtils'; +/** + * 일정 알림 시스템을 관리하는 훅 + * 이벤트 배열을 받아서 현재 시간 기준으로 알림이 필요한 이벤트들을 감지하고 알림을 생성/관리 + */ export const useNotifications = (events: Event[]) => { const [notifications, setNotifications] = useState<{ id: string; message: string }[]>([]); const [notifiedEvents, setNotifiedEvents] = useState([]); diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index be78512c..d31672bf 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -4,6 +4,7 @@ import { Event } from '../types.ts'; * 주어진 년도와 월의 일수를 반환합니다. */ export function getDaysInMonth(year: number, month: number): number { + if (month < 1 || month > 12) return 0; return new Date(year, month, 0).getDate(); } @@ -23,6 +24,9 @@ export function getWeekDates(date: Date): Date[] { return weekDates; } +/** + * 특정 월의 모든 주를 2차원 배열로 반환 + */ export function getWeeksAtMonth(currentDate: Date) { const year = currentDate.getFullYear(); const month = currentDate.getMonth(); @@ -51,10 +55,16 @@ export function getWeeksAtMonth(currentDate: Date) { return weeks; } +/** + * 특정 날짜(일)에 해당하는 이벤트들만 필터링하여 반환 + */ export function getEventsForDay(events: Event[], date: number): Event[] { return events.filter((event) => new Date(event.date).getDate() === date); } +/** + * 주어진 날짜가 속한 월의 몇 번쨰 주인지를 "YYYY년 M월 N주" 형식으로 반환 + */ export function formatWeek(targetDate: Date) { const dayOfWeek = targetDate.getDay(); const diffToThursday = 4 - dayOfWeek; @@ -97,10 +107,16 @@ export function isDateInRange(date: Date, rangeStart: Date, rangeEnd: Date): boo return normalizedDate >= normalizedStart && normalizedDate <= normalizedEnd; } +/** + * 숫자를 지정된 자릿수로 만들기 위해 앞에 0을 채워주는 함수 + */ export function fillZero(value: number, size = 2) { return String(value).padStart(size, '0'); } +/** + * Date 객체를 'YYYY-MM-DD" 형식의 문자열로 변환 + */ export function formatDate(currentDate: Date, day?: number) { return [ currentDate.getFullYear(), diff --git a/src/utils/eventOverlap.ts b/src/utils/eventOverlap.ts index 8412cee7..85dce904 100644 --- a/src/utils/eventOverlap.ts +++ b/src/utils/eventOverlap.ts @@ -4,6 +4,9 @@ export function parseDateTime(date: string, time: string) { return new Date(`${date}T${time}`); } +/** + * 이벤트 객체의 날짜와 시작/종료 시간을 완전한 Date 객체 범위로 변환 + */ export function convertEventToDateRange({ date, startTime, endTime }: Event | EventForm) { return { start: parseDateTime(date, startTime), diff --git a/src/utils/eventUtils.ts b/src/utils/eventUtils.ts index 9e75e947..49fc77c2 100644 --- a/src/utils/eventUtils.ts +++ b/src/utils/eventUtils.ts @@ -38,6 +38,9 @@ function filterEventsByDateRangeAtMonth(events: Event[], currentDate: Date) { return filterEventsByDateRange(events, monthStart, monthEnd); } +/** + * 주어진 이벤트 배열에서 검색어와 날짜 밤위를 기준으로 필터링된 이벤트들을 반환 + */ export function getFilteredEvents( events: Event[], searchTerm: string, diff --git a/src/utils/notificationUtils.ts b/src/utils/notificationUtils.ts index 16ba8bdd..291063f3 100644 --- a/src/utils/notificationUtils.ts +++ b/src/utils/notificationUtils.ts @@ -3,6 +3,9 @@ import { Event } from '../types'; const 초 = 1000; const 분 = 초 * 60; +/** + * 현재 시간을 기준으로 알림이 필요한 임박한 이벤트들을 필터링하요 반환 + */ export function getUpcomingEvents(events: Event[], now: Date, notifiedEvents: string[]) { return events.filter((event) => { const eventStart = new Date(`${event.date}T${event.startTime}`);