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/package.json b/package.json index b01b2b4b..4408059a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "build": "tsc -b && vite build", "lint:eslint": "eslint . --ext ts,tsx --report-unused-disable-directives", "lint:tsc": "tsc --pretty", - "lint": "pnpm lint:eslint && pnpm lint:tsc" + "lint": "pnpm lint:eslint && pnpm lint:tsc", + "preview": "vitest-preview" }, "dependencies": { "@emotion/react": "^11.11.4", @@ -26,9 +27,11 @@ "msw": "^2.10.3", "notistack": "^3.0.2", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "vitest-preview": "^0.0.1" }, "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093f3ec7..e0a550a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,13 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + vitest-preview: + specifier: ^0.0.1 + version: 0.0.1 devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -290,6 +296,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.15.18': + resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.5': resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} engines: {node: '>=18'} @@ -344,6 +356,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.15.18': + resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.5': resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} engines: {node: '>=18'} @@ -468,6 +486,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} @@ -895,9 +917,15 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -913,12 +941,27 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@4.19.6': + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + + '@types/express@4.17.23': + resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@18.19.123': + resolution: {integrity: sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==} + '@types/node@22.8.1': resolution: {integrity: sha512-k6Gi8Yyo8EtrNtkHXutUu2corfDf9su95VYVP10aGYMMROM6SAItZi0w1XszA6RtWTHSVp5OeFof37w0IEqCQg==} @@ -928,6 +971,12 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.1.6': resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} peerDependencies: @@ -941,6 +990,12 @@ packages: '@types/react@19.1.8': resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==} + '@types/send@0.17.5': + resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} + + '@types/serve-static@1.15.8': + resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} + '@types/statuses@2.0.5': resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} @@ -1038,6 +1093,9 @@ packages: peerDependencies: vite: ^4 || ^5 + '@vitest-preview/dev-utils@0.0.1': + resolution: {integrity: sha512-KLr4IvFz73dMao1tCHWgwqNJfHEcGOqHaQ7SHYfumrMvs2BBD4PKMBtePO2AV7+gq4iEPuIJY8INR3Oq5EnTUw==} + '@vitest/coverage-v8@2.1.3': resolution: {integrity: sha512-2OJ3c7UPoFSmBZwqD2VEkUw6A/tzPF0LmW0ZZhhB8PFxuc+9IBG/FaSM+RLEenc7ljzFvGN+G0nGQoZnh7sy2A==} peerDependencies: @@ -1549,11 +1607,136 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild-android-64@0.15.18: + resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + esbuild-android-arm64@0.15.18: + resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + esbuild-darwin-64@0.15.18: + resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + esbuild-darwin-arm64@0.15.18: + resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + esbuild-freebsd-64@0.15.18: + resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + esbuild-freebsd-arm64@0.15.18: + resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + esbuild-linux-32@0.15.18: + resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + esbuild-linux-64@0.15.18: + resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + esbuild-linux-arm64@0.15.18: + resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + esbuild-linux-arm@0.15.18: + resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + esbuild-linux-mips64le@0.15.18: + resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + esbuild-linux-ppc64le@0.15.18: + resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + esbuild-linux-riscv64@0.15.18: + resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + esbuild-linux-s390x@0.15.18: + resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + esbuild-netbsd-64@0.15.18: + resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + esbuild-openbsd-64@0.15.18: + resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: esbuild: '>=0.12 <1' + esbuild-sunos-64@0.15.18: + resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + esbuild-windows-32@0.15.18: + resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + esbuild-windows-64@0.15.18: + resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + esbuild-windows-arm64@0.15.18: + resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + esbuild@0.15.18: + resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.5: resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} engines: {node: '>=18'} @@ -3076,6 +3259,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} @@ -3112,6 +3298,31 @@ packages: eslint: '>=7' vite: '>=2' + vite@3.2.11: + resolution: {integrity: sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.0.2: resolution: {integrity: sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3152,6 +3363,10 @@ packages: yaml: optional: true + vitest-preview@0.0.1: + resolution: {integrity: sha512-rKh+rzW54HYfgYjCU/9n8t0V8rnxYiH67uJGYUKKqW5L87Cl8NESDzNe2BbD6WmNvM4ojQdc0VqLXv6QsDt1Jw==} + hasBin: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3513,6 +3728,9 @@ snapshots: '@esbuild/android-arm64@0.25.5': optional: true + '@esbuild/android-arm@0.15.18': + optional: true + '@esbuild/android-arm@0.25.5': optional: true @@ -3540,6 +3758,9 @@ snapshots: '@esbuild/linux-ia32@0.25.5': optional: true + '@esbuild/linux-loong64@0.15.18': + optional: true + '@esbuild/linux-loong64@0.25.5': optional: true @@ -3628,6 +3849,8 @@ snapshots: '@eslint/js@9.30.0': {} + '@eslint/js@9.33.0': {} + '@eslint/object-schema@2.1.6': {} '@eslint/plugin-kit@0.3.3': @@ -3990,10 +4213,19 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.8.1 + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.8.1 + '@types/cookie@0.6.0': {} '@types/deep-eql@4.0.2': {} @@ -4007,10 +4239,32 @@ snapshots: '@types/estree@1.0.8': {} + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 22.8.1 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.5 + + '@types/express@4.17.23': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.8 + + '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/mime@1.3.5': {} + + '@types/node@18.19.123': + dependencies: + undici-types: 5.26.5 + '@types/node@22.8.1': dependencies: undici-types: 6.19.8 @@ -4019,6 +4273,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@19.1.6(@types/react@19.1.8)': dependencies: '@types/react': 19.1.8 @@ -4031,6 +4289,17 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/send@0.17.5': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.8.1 + + '@types/serve-static@1.15.8': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.8.1 + '@types/send': 0.17.5 + '@types/statuses@2.0.5': {} '@types/tough-cookie@4.0.5': {} @@ -4172,6 +4441,10 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@vitest-preview/dev-utils@0.0.1': + dependencies: + open: 8.4.2 + '@vitest/coverage-v8@2.1.3(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 @@ -4861,6 +5134,54 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 + esbuild-android-64@0.15.18: + optional: true + + esbuild-android-arm64@0.15.18: + optional: true + + esbuild-darwin-64@0.15.18: + optional: true + + esbuild-darwin-arm64@0.15.18: + optional: true + + esbuild-freebsd-64@0.15.18: + optional: true + + esbuild-freebsd-arm64@0.15.18: + optional: true + + esbuild-linux-32@0.15.18: + optional: true + + esbuild-linux-64@0.15.18: + optional: true + + esbuild-linux-arm64@0.15.18: + optional: true + + esbuild-linux-arm@0.15.18: + optional: true + + esbuild-linux-mips64le@0.15.18: + optional: true + + esbuild-linux-ppc64le@0.15.18: + optional: true + + esbuild-linux-riscv64@0.15.18: + optional: true + + esbuild-linux-s390x@0.15.18: + optional: true + + esbuild-netbsd-64@0.15.18: + optional: true + + esbuild-openbsd-64@0.15.18: + optional: true + esbuild-register@3.6.0(esbuild@0.25.5): dependencies: debug: 4.4.1 @@ -4868,6 +5189,43 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild-sunos-64@0.15.18: + optional: true + + esbuild-windows-32@0.15.18: + optional: true + + esbuild-windows-64@0.15.18: + optional: true + + esbuild-windows-arm64@0.15.18: + optional: true + + esbuild@0.15.18: + optionalDependencies: + '@esbuild/android-arm': 0.15.18 + '@esbuild/linux-loong64': 0.15.18 + esbuild-android-64: 0.15.18 + esbuild-android-arm64: 0.15.18 + esbuild-darwin-64: 0.15.18 + esbuild-darwin-arm64: 0.15.18 + esbuild-freebsd-64: 0.15.18 + esbuild-freebsd-arm64: 0.15.18 + esbuild-linux-32: 0.15.18 + esbuild-linux-64: 0.15.18 + esbuild-linux-arm: 0.15.18 + esbuild-linux-arm64: 0.15.18 + esbuild-linux-mips64le: 0.15.18 + esbuild-linux-ppc64le: 0.15.18 + esbuild-linux-riscv64: 0.15.18 + esbuild-linux-s390x: 0.15.18 + esbuild-netbsd-64: 0.15.18 + esbuild-openbsd-64: 0.15.18 + esbuild-sunos-64: 0.15.18 + esbuild-windows-32: 0.15.18 + esbuild-windows-64: 0.15.18 + esbuild-windows-arm64: 0.15.18 + esbuild@0.25.5: optionalDependencies: '@esbuild/aix-ppc64': 0.25.5 @@ -6627,6 +6985,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@6.19.8: {} universalify@0.2.0: {} @@ -6675,6 +7035,16 @@ snapshots: rollup: 2.79.2 vite: 7.0.2(@types/node@22.8.1) + vite@3.2.11(@types/node@18.19.123): + dependencies: + esbuild: 0.15.18 + postcss: 8.5.6 + resolve: 1.22.8 + rollup: 2.79.2 + optionalDependencies: + '@types/node': 18.19.123 + fsevents: 2.3.3 + vite@7.0.2(@types/node@22.8.1): dependencies: esbuild: 0.25.5 @@ -6687,6 +7057,21 @@ snapshots: '@types/node': 22.8.1 fsevents: 2.3.3 + vitest-preview@0.0.1: + dependencies: + '@types/express': 4.17.23 + '@types/node': 18.19.123 + '@vitest-preview/dev-utils': 0.0.1 + express: 4.21.1 + vite: 3.2.11(@types/node@18.19.123) + transitivePeerDependencies: + - less + - sass + - stylus + - sugarss + - supports-color + - terser + vitest@3.2.4(@types/node@22.8.1)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3)): dependencies: '@types/chai': 5.2.2 diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..6c1c6e06 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,64 +1,18 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material'; -import { - Alert, - AlertTitle, - Box, - Button, - Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - FormControl, - FormControlLabel, - FormLabel, - IconButton, - MenuItem, - Select, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TextField, - Tooltip, - Typography, -} from '@mui/material'; +import { Box, Stack } from '@mui/material'; import { useSnackbar } from 'notistack'; import { useState } from 'react'; +import { CalendarView } from './components/calendar'; +import { OverlapDialog } from './components/dialog'; +import { EventFormComponent, EventList } from './components/event'; +import { NotificationToast } from './components/notification'; import { useCalendarView } from './hooks/useCalendarView.ts'; import { useEventForm } from './hooks/useEventForm.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; import { useNotifications } from './hooks/useNotifications.ts'; import { useSearch } from './hooks/useSearch.ts'; -// import { Event, EventForm, RepeatType } from './types'; import { Event, EventForm } from './types'; -import { - formatDate, - formatMonth, - formatWeek, - getEventsForDay, - getWeekDates, - getWeeksAtMonth, -} from './utils/dateUtils'; import { findOverlappingEvents } from './utils/eventOverlap'; -import { getTimeErrorMessage } from './utils/timeValidation'; - -const categories = ['업무', '개인', '가족', '기타']; - -const weekDays = ['일', '월', '화', '수', '목', '금', '토']; - -const notificationOptions = [ - { value: 1, label: '1분 전' }, - { value: 10, label: '10분 전' }, - { value: 60, label: '1시간 전' }, - { value: 120, label: '2시간 전' }, - { value: 1440, label: '1일 전' }, -]; function App() { const { @@ -107,6 +61,27 @@ function App() { const { enqueueSnackbar } = useSnackbar(); + const handleOverlapConfirm = () => { + setIsOverlapDialogOpen(false); + saveEvent({ + id: editingEvent ? editingEvent.id : undefined, + title, + date, + startTime, + endTime, + description, + location, + category, + repeat: { + type: isRepeating ? repeatType : 'none', + interval: repeatInterval, + endDate: repeatEndDate || undefined, + }, + notificationTime, + }); + resetForm(); + }; + const addOrUpdateEvent = async () => { if (!title || !date || !startTime || !endTime) { enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); @@ -145,514 +120,73 @@ function App() { } }; - const renderWeekView = () => { - const weekDates = getWeekDates(currentDate); - return ( - - {formatWeek(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - - {weekDates.map((date) => ( - - - {date.getDate()} - - {filteredEvents - .filter( - (event) => new Date(event.date).toDateString() === date.toDateString() - ) - .map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - ))} - - -
-
-
- ); - }; - - const renderMonthView = () => { - const weeks = getWeeksAtMonth(currentDate); - - return ( - - {formatMonth(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - {weeks.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => { - const dateString = day ? formatDate(currentDate, day) : ''; - const holiday = holidays[dateString]; - - return ( - - {day && ( - <> - - {day} - - {holiday && ( - - {holiday} - - )} - {getEventsForDay(filteredEvents, day).map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - )} - - ); - })} - - ))} - -
-
-
- ); - }; - return ( - - {editingEvent ? '일정 수정' : '일정 추가'} - - - 제목 - setTitle(e.target.value)} - /> - - - - 날짜 - setDate(e.target.value)} - /> - - - - - 시작 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!startTimeError} - /> - - - - 종료 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!endTimeError} - /> - - - - - - 설명 - setDescription(e.target.value)} - /> - - - - 위치 - setLocation(e.target.value)} - /> - - - - 카테고리 - - - - - setIsRepeating(e.target.checked)} - /> - } - label="반복 일정" - /> - - - - 알림 설정 - - - - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( - - - 반복 유형 - - - - - 반복 간격 - setRepeatInterval(Number(e.target.value))} - slotProps={{ htmlInput: { min: 1 } }} - /> - - - 반복 종료일 - setRepeatEndDate(e.target.value)} - /> - - - - )} */} - - - - - - 일정 보기 - - - navigate('prev')}> - - - - navigate('next')}> - - - - - {view === 'week' && renderWeekView()} - {view === 'month' && renderMonthView()} - - - - - 일정 검색 - setSearchTerm(e.target.value)} - /> - - - {filteredEvents.length === 0 ? ( - 검색 결과가 없습니다. - ) : ( - filteredEvents.map((event) => ( - - - - - {notifiedEvents.includes(event.id) && } - - {event.title} - - - {event.date} - - {event.startTime} - {event.endTime} - - {event.description} - {event.location} - 카테고리: {event.category} - {event.repeat.type !== 'none' && ( - - 반복: {event.repeat.interval} - {event.repeat.type === 'daily' && '일'} - {event.repeat.type === 'weekly' && '주'} - {event.repeat.type === 'monthly' && '월'} - {event.repeat.type === 'yearly' && '년'} - 마다 - {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`} - - )} - - 알림:{' '} - { - notificationOptions.find( - (option) => option.value === event.notificationTime - )?.label - } - - - - editEvent(event)}> - - - deleteEvent(event.id)}> - - - - - - )) - )} - + + + + + - setIsOverlapDialogOpen(false)}> - 일정 겹침 경고 - - - 다음 일정과 겹칩니다: - {overlappingEvents.map((event) => ( - - {event.title} ({event.date} {event.startTime}-{event.endTime}) - - ))} - 계속 진행하시겠습니까? - - - - - - - + setIsOverlapDialogOpen(false)} + overlappingEvents={overlappingEvents} + onConfirm={handleOverlapConfirm} + /> - {notifications.length > 0 && ( - - {notifications.map((notification, index) => ( - setNotifications((prev) => prev.filter((_, i) => i !== index))} - > - - - } - > - {notification.message} - - ))} - - )} + ); } diff --git a/src/__tests__/components/CalendarView.test.tsx b/src/__tests__/components/CalendarView.test.tsx new file mode 100644 index 00000000..247eea37 --- /dev/null +++ b/src/__tests__/components/CalendarView.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; + +import { CalendarView } from '../../components/calendar/CalendarView'; +import { createMockEvents } from '../utils'; + +const renderCalendarView = (props: Parameters[0]) => + render(); + +describe('CalendarView', () => { + const mockEvents = createMockEvents(); + + const mockHolidays = { + '2025-01-01': '신정', + '2025-01-15': '설날', + }; + + const mockSetView = vi.fn(); + const mockNavigate = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + it('Week이 선택되었을 때 해당 날짜의 주간 일정이 렌더링된다', () => { + renderCalendarView({ + view: 'week', + setView: mockSetView, + currentDate: new Date('2025-01-15'), + navigate: mockNavigate, + filteredEvents: mockEvents, + notifiedEvents: [], + holidays: mockHolidays, + }); + + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + expect(screen.queryByTestId('month-view')).not.toBeInTheDocument(); + }); + + it('휴일 정보가 올바르게 표시된다', () => { + renderCalendarView({ + view: 'month', + setView: mockSetView, + currentDate: new Date('2025-01-15'), + navigate: mockNavigate, + filteredEvents: mockEvents, + notifiedEvents: [], + holidays: mockHolidays, + }); + + expect(screen.getByText('설날')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/EventList.test.tsx b/src/__tests__/components/EventList.test.tsx new file mode 100644 index 00000000..b88688bf --- /dev/null +++ b/src/__tests__/components/EventList.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; + +import { EventList } from '../../components/event/EventList'; +import { createMockEvents } from '../utils'; + +const renderEventList = (props: Parameters[0]) => + render(); + +describe('EventList', () => { + const mockEvents = createMockEvents(); + + const mockSetSearchTerm = vi.fn(); + const mockEditEvent = vi.fn(); + const mockDeleteEvent = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('검색 입력창과 일정 목록이 표시된다', () => { + renderEventList({ + searchTerm: '', + setSearchTerm: mockSetSearchTerm, + filteredEvents: mockEvents, + notifiedEvents: [], + editEvent: mockEditEvent, + deleteEvent: mockDeleteEvent, + }); + + expect(screen.getByPlaceholderText('검색어를 입력하세요')).toBeInTheDocument(); + expect(screen.getByTestId('event-list')).toBeInTheDocument(); + }); + + it('일정 정보가 올바르게 표시된다', () => { + renderEventList({ + searchTerm: '', + setSearchTerm: mockSetSearchTerm, + filteredEvents: mockEvents, + notifiedEvents: [], + editEvent: mockEditEvent, + deleteEvent: mockDeleteEvent, + }); + + expect(screen.getByText('팀 회의')).toBeInTheDocument(); + expect(screen.getByText('회의실 A')).toBeInTheDocument(); + }); + + it('반복 일정의 정보가 올바르게 표시된다', () => { + renderEventList({ + searchTerm: '', + setSearchTerm: mockSetSearchTerm, + filteredEvents: mockEvents, + notifiedEvents: [], + editEvent: mockEditEvent, + deleteEvent: mockDeleteEvent, + }); + + expect(screen.getByText('반복: 1주마다 (종료: 2025-12-31)')).toBeInTheDocument(); + }); + + it('검색 결과가 없을 때 검색 결과가 없습니다. 라는 메시지가 표시된다', () => { + renderEventList({ + searchTerm: '', + setSearchTerm: mockSetSearchTerm, + filteredEvents: [], + notifiedEvents: [], + editEvent: mockEditEvent, + deleteEvent: mockDeleteEvent, + }); + + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..84011180 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -1,24 +1,86 @@ import { act, renderHook } from '@testing-library/react'; import { useCalendarView } from '../../hooks/useCalendarView.ts'; -import { assertDate } from '../utils.ts'; describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.view).toBe('month'); + }); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.currentDate).toStrictEqual(new Date('2025-10-01')); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + }); }); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); +it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); + // act는 배치 업데이트가 완료될때까지 기다린다. + act(() => { + result.current.setView('week'); + }); + expect(result.current.view).toBe('week'); +}); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); +it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.setView('week'); + }); + act(() => { + result.current.navigate('next'); + }); + expect(result.current.currentDate).toStrictEqual(new Date('2025-10-08')); +}); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); +it("주간 뷰에서 이전으로 navigate시 7일 전 '2025-09-24' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + act(() => { + result.current.setView('week'); + }); + act(() => { + result.current.navigate('prev'); + }); + expect(result.current.currentDate).toStrictEqual(new Date('2025-09-24')); +}); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); +it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.navigate('next'); + }); + expect(result.current.currentDate).toStrictEqual(new Date('2025-11-01')); +}); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); +it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.navigate('prev'); + }); + expect(result.current.currentDate).toStrictEqual(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')); + }); + + 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..090390cf 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -3,12 +3,129 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; import { Event } from '../../types.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const events = [ + { + title: '기존 회의', + date: '2025-10-15', + description: '기존 팀 미팅', + location: '회의실 B', + }, + { + title: '변경 회의', + date: '2025-10-16', + description: '변경 팀 미팅', + location: '회의실 B2', + }, + ] as Event[]; + const { result } = renderHook(() => useSearch(events, new Date('2025-10-15'), 'week')); + expect(result.current.filteredEvents).toEqual(events); +}); -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); +it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const events = [ + { + title: '기존 회의', + date: '2025-10-15', + description: '기존 팀 미팅', + location: '회의실 B', + }, + { + title: '변경 회의', + date: '2025-10-16', + description: '변경 팀 미팅', + location: '회의실 B2', + }, + ] as Event[]; -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); + const { result } = renderHook(() => useSearch(events, new Date('2025-10-01'), 'month')); + act(() => { + result.current.setSearchTerm('변경'); + }); + expect(result.current.filteredEvents).toEqual([events[1]]); +}); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); +it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const events = [ + { + title: '팀 변경 회의', + date: '2025-10-15', + description: '팀 미팅 회의', + location: '팀 회의실 B1', + }, + { + title: 'React 회의', + date: '2025-10-16', + description: '미팅 회의', + location: '팀 회의실 B2', + }, + ] as Event[]; + const { result } = renderHook(() => useSearch(events, new Date('2025-10-01'), 'month')); + act(() => { + result.current.setSearchTerm('B1'); + }); + expect(result.current.filteredEvents).toEqual([events[0]]); +}); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); +it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const events = [ + { + title: '팀 변경 회의', + date: '2025-09-01', + description: '월간 미팅 회의', + location: '팀 회의실 B1', + }, + { + title: '중간 회의', + date: '2025-09-15', + description: '팀 미팅 회의', + location: '팀 회의실 B2', + }, + { + title: '월간 회의', + date: '2025-10-01', + description: '월간 미팅 회의', + location: '팀 회의실 B3', + }, + ] as Event[]; + const { result: result1 } = renderHook(() => useSearch(events, new Date('2025-10-03'), 'week')); + expect(result1.current.filteredEvents).toEqual([events[2]]); + + const { result: result2 } = renderHook(() => useSearch(events, new Date('2025-09-29'), 'month')); + expect(result2.current.filteredEvents).toEqual([events[0], events[1]]); +}); + +it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const events = [ + { + title: '회의', + date: '2025-10-01', + description: '회의', + location: '팀 회의실 B1', + }, + { + title: '점심', + date: '2025-10-01', + description: '점심', + location: '팀 회의실 B1', + }, + { + title: '기타', + date: '2025-10-01', + description: '기타', + location: '팀 회의실 B1', + }, + ] as Event[]; + + const { result } = renderHook(() => useSearch(events, new Date('2025-10-01'), 'week')); + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.filteredEvents).toEqual(events); + + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toEqual([events[1]]); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..037ea90f 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -10,8 +10,10 @@ import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; import { Event } from '../../types.ts'; +// 테스트에서 토스트 메시지 호출을 추적하기 위한 mock 함수 생성 const enqueueSnackbarFn = vi.fn(); +// notistack의 useSnackbar 훅을 mock 처리하여 실제 토스트 대신 mock 함수가 호출되도록 설정 vi.mock('notistack', async () => { const actual = await vi.importActual('notistack'); return { @@ -22,16 +24,215 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +// events.json, realEvent.json 파일은 실제 handlers에서 쓰는 데이터(DB)이기 때문에 테스트 환경에서 쓰면 문제가 생김. +// 따로 이벤트 데이터를 만들어서 테스트 해야함. -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); +const events = [ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '10:00', + endTime: '11:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + { + id: '2', + title: '점심 약속', + date: '2025-10-01', + startTime: '12:30', + endTime: '13:30', + description: '동료와 점심 식사', + location: '구내식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + { + id: '3', + title: '기본 업무 마감', + date: '2025-10-01', + startTime: '14:00', + endTime: '16:00', + description: '기본 업무 마감', + location: '사무실', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + { + id: '4', + title: '생일 파티', + date: '2025-10-01', + startTime: '19:00', + endTime: '22:00', + description: '내 생일 파티', + location: '집', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + { + id: '5', + title: '과제', + date: '2025-10-01', + startTime: '22:30', + endTime: '01:30', + description: '기본 과제', + location: '집', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, +] as Event[]; -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); +it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + setupMockHandlerCreation(events); + const { result } = renderHook(() => useEventOperations(false)); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + await act(async () => { + await result.current.fetchEvents(); + }); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + expect(result.current.events).toEqual(events); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); +}); + +it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + setupMockHandlerCreation(events); + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.saveEvent({ + id: '6', + title: '아침 기상', + date: '2025-10-02', + startTime: '06:30', + endTime: '06:50', + description: '아침 기상', + location: '집', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + } as Event); + }); + + expect(result.current.events).toEqual([ + ...events, + { + id: '6', + title: '아침 기상', + date: '2025-10-02', + startTime: '06:30', + endTime: '06:50', + description: '아침 기상', + location: '집', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + } as Event, + ]); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', { variant: 'success' }); +}); + +it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + setupMockHandlerUpdating(); + + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.fetchEvents(); + }); + + const prevEvent = result.current.events[0]; + + await act(async () => { + await result.current.saveEvent({ + ...prevEvent, + title: '부서 회의', + endTime: '11:30', + } as Event); + }); + + expect(result.current.events[0].title).toBe('부서 회의'); + expect(result.current.events[0].endTime).toBe('11:30'); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', { variant: 'success' }); +}); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); +it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + setupMockHandlerDeletion(); + const { result } = renderHook(() => useEventOperations(false)); -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + await act(async () => { + await result.current.fetchEvents(); + }); + + await act(async () => { + await result.current.deleteEvent('1'); + }); + + expect(result.current.events).not.toContain(events.find((event) => event.id === '1')); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { variant: 'info' }); +}); + +it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ error: '이벤트 로딩 실패' }, { status: 500 }); + }) + ); + const { result } = renderHook(() => useEventOperations(false)); + await act(async () => { + await result.current.fetchEvents(); + }); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); +}); + +it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + server.use( + http.put('/api/events/:id', async () => { + return HttpResponse.json({ error: '이벤트가 존재하지 않습니다.' }, { status: 500 }); + }) + ); + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.fetchEvents(); + }); + + await act(async () => { + await result.current.saveEvent({ + id: '9', + title: '팀 회의', + date: '2025-10-01', + startTime: '10:00', + endTime: '11:00', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + } as Event); + }); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); +}); + +it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + server.use( + http.delete('/api/events/:id', async () => { + return HttpResponse.json({ error: '일정 삭제 실패' }, { status: 500 }); + }) + ); + const { result } = renderHook(() => useEventOperations(false)); + await act(async () => { + await result.current.fetchEvents(); + }); + await act(async () => { + await result.current.deleteEvent('1'); + }); + 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..4960a968 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -2,13 +2,65 @@ import { act, renderHook } from '@testing-library/react'; import { useNotifications } from '../../hooks/useNotifications.ts'; import { Event } from '../../types.ts'; -import { formatDate } from '../../utils/dateUtils.ts'; -import { parseHM } from '../utils.ts'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +const events = [ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '00:20', + endTime: '07:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, +] as Event[]; -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); +beforeAll(() => { + vi.useFakeTimers(); +}); -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); +afterAll(() => { + vi.useRealTimers(); +}); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); +const skipTime = (ms: number) => { + act(() => { + vi.advanceTimersByTime(ms); + }); +}; + +it('초기 상태에서는 알림이 없어야 한다', () => { + const { result } = renderHook(() => useNotifications(events)); + expect(result.current.notifications).toEqual([]); +}); + +it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + const { result } = renderHook(() => useNotifications(events)); + skipTime(1000 * 60 * 10); + + expect(result.current.notifications).toEqual([ + { id: '1', message: '10분 후 팀 회의 일정이 시작됩니다.' }, + ]); +}); + +it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const { result } = renderHook(() => useNotifications(events)); + skipTime(1000 * 60 * 10); + act(() => { + result.current.removeNotification(0); + }); + + expect(result.current.notifications).toEqual([]); +}); + +it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + const { result } = renderHook(() => useNotifications(events)); + skipTime(1000 * 60 * 10); + expect(result.current.notifications).toEqual([ + { id: '1', message: '10분 후 팀 회의 일정이 시작됩니다.' }, + ]); + expect(result.current.notifiedEvents).toEqual(['1']); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 0b559b44..c18735ca 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,8 +1,7 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { UserEvent, userEvent } from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; import { ReactElement } from 'react'; @@ -12,7 +11,6 @@ import { setupMockHandlerUpdating, } from '../__mocks__/handlersUtils'; import App from '../App'; -import { server } from '../setupTests'; import { Event } from '../types'; const theme = createTheme(); @@ -59,37 +57,324 @@ const saveSchedule = async ( describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + setupMockHandlerCreation([]); + + const { user } = setup(); + await saveSchedule(user, { + title: '팀 회의', + date: '2025-10-01', + startTime: '06:30', + endTime: '07:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + }); + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('팀 회의')).toBeInTheDocument(); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + setupMockHandlerUpdating(); + + const { user } = setup(); + + const [editButton] = await screen.findAllByLabelText('Edit event'); + await user.click(editButton); + + await user.clear(screen.getByLabelText('제목')); + await user.type(screen.getByLabelText('제목'), '가족 회의'); + await user.clear(screen.getByLabelText('위치')); + await user.type(screen.getByLabelText('위치'), '회의실 B'); + await user.click(screen.getByTestId('event-submit-button')); + + await screen.findByText('일정이 수정되었습니다.'); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('가족 회의')).toBeInTheDocument(); + expect(await eventList.findByText('회의실 B')).toBeInTheDocument(); + }); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + setupMockHandlerDeletion(); + + const { user } = setup(); + + const [deleteButton] = await screen.findAllByLabelText('Delete event'); + await user.click(deleteButton); + + await screen.findByText('일정이 삭제되었습니다.'); + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + const { user } = setup(); + + await user.click(screen.getByLabelText('뷰 타입 선택')); + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'week-option' })); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '06:30', + endTime: '07:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + const { user } = setup(); + + await user.click(screen.getByLabelText('뷰 타입 선택')); + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'week-option' })); + + const weekView = within(screen.getByTestId('week-view')); + expect(await weekView.findByText('팀 회의')).toBeInTheDocument(); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('회의실 A')).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + setupMockHandlerCreation([]); + + setup(); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '이사가는 날', + date: '2025-10-15', + startTime: '11:30', + endTime: '13:00', + description: '이사가는 날 날씨 좋다!', + location: '봉천동', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, + ]); + setup(); + + const monthView = within(screen.getByTestId('month-view')); + expect(await monthView.findByText('이사가는 날')).toBeInTheDocument(); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('봉천동')).toBeInTheDocument(); + }); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + setupMockHandlerCreation([]); + const { user } = setup(); + + // 왜 26년 1월 1일에는 신정 표시 안해주냐...ㅠ + for (let i = 0; i < 9; i++) { + await user.click(screen.getByLabelText('Previous')); + } + const monthView = within(screen.getByTestId('month-view')); + expect(await monthView.findByText('신정')).toBeInTheDocument(); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '08:30', + endTime: '09:00', + description: '회의 하기 싫어', + location: '회사', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2025-10-02', + startTime: '12:30', + endTime: '14:00', + description: '쌀국수 먹어야지', + location: '쌀국수 맛집', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, + ]); + + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.click(searchInput); + await user.type(searchInput, '집에 가고싶다'); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '08:30', + endTime: '09:00', + description: '회의 하기 싫어', + location: '회사', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.click(searchInput); + await user.type(searchInput, '팀 회의'); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('팀 회의')).toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '08:30', + endTime: '09:00', + description: '회의 하기 싫어', + location: '회사', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2025-10-02', + startTime: '12:30', + endTime: '14:00', + description: '쌀국수 먹어야지', + location: '쌀국수 맛집', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, + { + id: '3', + title: '야구 직관', + date: '2025-10-10', + startTime: '18:30', + endTime: '21:00', + description: 'KT 응원', + location: '수원KT종합경기장', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, + ]); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + const { user } = setup(); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.click(searchInput); + await user.clear(searchInput); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('팀 회의')).toBeInTheDocument(); + expect(await eventList.findByText('점심 약속')).toBeInTheDocument(); + expect(await eventList.findByText('야구 직관')).toBeInTheDocument(); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '08:30', + endTime: '09:00', + description: '회의 하기 싫어', + location: '회사', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await saveSchedule(user, { + title: '부서 회의', + date: '2025-10-01', + startTime: '08:00', + endTime: '10:00', + description: '부서 회의', + location: '회사', + category: '업무', + }); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + setupMockHandlerUpdating(); + + const { user } = setup(); + + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); + await user.click(editButtons[1]); + await user.clear(screen.getByLabelText('시작 시간')); + await user.type(screen.getByLabelText('시작 시간'), '08:00'); + await user.click(screen.getByTestId('event-submit-button')); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '물 마시기', + date: '2025-10-01', + startTime: '00:10', + endTime: '00:50', + description: '물 마시기', + location: '집', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + setup(); + // 일정이 로딩되어야 notification에 Id가 추가되고 setInterval이 실행되기 때문에 추가 + await screen.findByText('일정 로딩 완료!'); + expect(await screen.findByText('10분 후 물 마시기 일정이 시작됩니다.')).toBeInTheDocument(); +}); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..28532816 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -12,105 +12,348 @@ import { } from '../../utils/dateUtils'; describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); - - it('4월은 30일 일수를 반환한다', () => {}); - - it('윤년의 2월에 대해 29일을 반환한다', () => {}); - - it('평년의 2월에 대해 28일을 반환한다', () => {}); - - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); + it('1월은 31일 수를 반환한다', () => { + expect(getDaysInMonth(2025, 1)).toBe(31); + }); + + it('4월은 30일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 4)).toBe(30); + }); + + it('윤년의 2월에 대해 29일을 반환한다', () => { + expect(getDaysInMonth(2024, 2)).toBe(29); + }); + + it('평년의 2월에 대해 28일을 반환한다', () => { + expect(getDaysInMonth(2025, 2)).toBe(28); + }); + + // 불필요한 테스트 : 월이 13월인 경우는 없음 (라이브러리에서 13월은 선택할수 없음) + it.skip('유효하지 않은 월에 대해 적절히 처리한다', () => { + expect(getDaysInMonth(2025, 13)).toBe(31); + }); }); describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); - - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); - - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = new Date('2025-08-20'); + const weekDates = getWeekDates(date); + + expect(weekDates).toEqual([ + new Date('2025-08-17'), + new Date('2025-08-18'), + new Date('2025-08-19'), + new Date('2025-08-20'), + new Date('2025-08-21'), + new Date('2025-08-22'), + new Date('2025-08-23'), + ]); + }); + + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = new Date('2025-08-18'); + const weekDates = getWeekDates(date); + + expect(weekDates).toEqual([ + new Date('2025-08-17'), + new Date('2025-08-18'), + new Date('2025-08-19'), + new Date('2025-08-20'), + new Date('2025-08-21'), + new Date('2025-08-22'), + new Date('2025-08-23'), + ]); + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = new Date('2025-08-24'); + const weekDates = getWeekDates(date); + + expect(weekDates).toEqual([ + new Date('2025-08-24'), + new Date('2025-08-25'), + new Date('2025-08-26'), + new Date('2025-08-27'), + new Date('2025-08-28'), + new Date('2025-08-29'), + new Date('2025-08-30'), + ]); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + const date = new Date('2025-12-31'); + const weekDates = getWeekDates(date); + + expect(weekDates).toEqual([ + new Date('2025-12-28'), + new Date('2025-12-29'), + new Date('2025-12-30'), + new Date('2025-12-31'), + new Date('2026-01-01'), + new Date('2026-01-02'), + new Date('2026-01-03'), + ]); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + const date = new Date('2026-01-01'); + const weekDates = getWeekDates(date); + + expect(weekDates).toEqual([ + new Date('2025-12-28'), + new Date('2025-12-29'), + new Date('2025-12-30'), + new Date('2025-12-31'), + new Date('2026-01-01'), + new Date('2026-01-02'), + new Date('2026-01-03'), + ]); + }); + + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + const date = new Date('2024-02-29'); + const weekDates = getWeekDates(date); + + expect(weekDates).toEqual([ + new Date('2024-02-25'), + new Date('2024-02-26'), + new Date('2024-02-27'), + new Date('2024-02-28'), + new Date('2024-02-29'), + new Date('2024-03-01'), + new Date('2024-03-02'), + ]); + }); + + it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => { + const date = new Date('2025-08-31'); + const weekDates = getWeekDates(date); + + expect(weekDates).toEqual([ + new Date('2025-08-31'), + new Date('2025-09-01'), + new Date('2025-09-02'), + new Date('2025-09-03'), + new Date('2025-09-04'), + new Date('2025-09-05'), + new Date('2025-09-06'), + ]); + }); }); describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); + it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => { + const date = new Date('2025-07-01'); + const weeks = getWeeksAtMonth(date); + + expect(weeks).toEqual([ + [null, null, 1, 2, 3, 4, 5], + [6, 7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18, 19], + [20, 21, 22, 23, 24, 25, 26], + [27, 28, 29, 30, 31, null, null], + ]); + }); }); describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); - - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + const events = [ + { + date: '2025-08-01', + }, + { + date: '2025-08-02', + }, + ] as Event[]; + const eventsForDay = getEventsForDay(events, 1); + expect(eventsForDay).toEqual([ + { + date: '2025-08-01', + }, + ]); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + const events = [ + { + date: '2025-08-01', + }, + { + date: '2025-08-02', + }, + ] as Event[]; + const eventsForDay = getEventsForDay(events, 3); + expect(eventsForDay).toEqual([]); + }); + + // 불필요한 테스트 : 날짜가 0일 경우는 없음 (라이브러리에서 0일은 선택할수 없음) + it.skip('날짜가 0일 경우 빈 배열을 반환한다', () => { + const events = [{ date: '2025-08-01' }] as Event[]; + const eventsForDay = getEventsForDay(events, 0); + expect(eventsForDay).toEqual([]); + }); + + // 불필요한 테스트 : 날짜가 32일 이상인 경우는 없음 (라이브러리에서 32일은 선택할수 없음) + it.skip('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + const events = [ + { + date: '2025-08-01', + }, + ] as Event[]; + const eventsForDay = getEventsForDay(events, 32); + expect(eventsForDay).toEqual([]); + }); }); describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); - - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-08-15'); + const week = formatWeek(date); + expect(week).toBe('2025년 8월 2주'); + }); + + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-08-01'); + const week = formatWeek(date); + expect(week).toBe('2025년 7월 5주'); + }); + + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-08-31'); + const week = formatWeek(date); + expect(week).toBe('2025년 9월 1주'); + }); + + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-12-31'); + const week = formatWeek(date); + expect(week).toBe('2026년 1월 1주'); + }); + + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2024-02-29'); + const week = formatWeek(date); + expect(week).toBe('2024년 2월 5주'); + }); + + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-02-28'); + const week = formatWeek(date); + expect(week).toBe('2025년 2월 4주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + const date = new Date('2025-07-10'); + const month = formatMonth(date); + + expect(month).toBe('2025년 7월'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); - - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); - - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); - - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); - - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); - - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + expect( + isDateInRange(new Date('2025-07-10'), new Date('2025-07-01'), new Date('2025-07-31')) + ).toBe(true); + }); + + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + expect( + isDateInRange(new Date('2025-07-01'), new Date('2025-07-01'), new Date('2025-07-31')) + ).toBe(true); + }); + + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + expect( + isDateInRange(new Date('2025-07-31'), new Date('2025-07-01'), new Date('2025-07-31')) + ).toBe(true); + }); + + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + expect( + isDateInRange(new Date('2025-06-30'), new Date('2025-07-01'), new Date('2025-07-31')) + ).toBe(false); + }); + + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + expect( + isDateInRange(new Date('2025-08-01'), new Date('2025-07-01'), new Date('2025-07-31')) + ).toBe(false); + }); + + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + expect( + isDateInRange(new Date('2025-07-31'), new Date('2025-08-01'), new Date('2025-07-01')) + ).toBe(false); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); - - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); - - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); - - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); - - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); - - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); - - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); - - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); - - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + expect(fillZero(5)).toBe('05'); + }); + + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + expect(fillZero(10)).toBe('10'); + }); + + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + expect(fillZero(3, 3)).toBe('003'); + }); + + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + expect(fillZero(100)).toBe('100'); + }); + + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + expect(fillZero(0)).toBe('00'); + }); + + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + expect(fillZero(1, 5)).toBe('00001'); + }); + + // 3.14는 자리수가 4자리임 (.) 포함 + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + expect(fillZero(3.14, 5)).toBe('03.14'); + }); + + // 불필요한 테스트 : fillZero 의 기본 동작은 불필요한 테스트 + it.skip('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + expect(fillZero(1)).toBe('01'); + }); + + // 불필요한 테스트 : padStart 의 기본 동작은 불필요한 테스트 + it.skip('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + expect(fillZero(100, 2)).toBe('100'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); - - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); - - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); - - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + const date = new Date(2025, 7, 1); + const formattedDate = formatDate(date); + expect(formattedDate).toBe('2025-08-01'); + }); + + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + const date = new Date(2025, 7, 1); + const formattedDate = formatDate(date, 10); + expect(formattedDate).toBe('2025-08-10'); + }); + + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const date = new Date('2025-8-10'); + const formattedDate = formatDate(date); + expect(formattedDate).toBe('2025-08-10'); + }); + + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const date = new Date('2025-12-1'); + const formattedDate = formatDate(date); + expect(formattedDate).toBe('2025-12-01'); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..984b01a9 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -1,4 +1,4 @@ -import { Event } from '../../types'; +import { Event, EventForm } from '../../types'; import { convertEventToDateRange, findOverlappingEvents, @@ -6,31 +6,156 @@ import { parseDateTime, } from '../../utils/eventOverlap'; describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + const date = parseDateTime('2025-07-01', '14:30'); + expect(date).toEqual(new Date('2025-07-01T14:30')); + }); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + const date = parseDateTime('011-123-4567', '15:30'); + expect(date).toEqual(new Date('Invalid Date')); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + const date = parseDateTime('2025-07-01', '26:80'); + expect(date).toEqual(new Date('Invalid Date')); + }); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + const date = parseDateTime('', '14:30'); + expect(date).toEqual(new Date('Invalid Date')); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const event = { + date: '2025-07-01', + startTime: '14:30', + endTime: '15:30', + } as EventForm; - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + expect(convertEventToDateRange(event)).toEqual({ + start: new Date('2025-07-01T14:30'), + end: new Date('2025-07-01T15:30'), + }); + }); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event = { + date: '011-123-4567', + startTime: '14:30', + endTime: '15:30', + } as EventForm; + + expect(convertEventToDateRange(event)).toEqual({ + start: new Date('Invalid Date'), + end: new Date('Invalid Date'), + }); + }); + + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event = { + date: '2025-07-01', + startTime: '29:30', + endTime: '50:30', + } as EventForm; + + expect(convertEventToDateRange(event)).toEqual({ + start: new Date('Invalid Date'), + end: new Date('Invalid Date'), + }); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + const event1 = { + date: '2025-07-01', + startTime: '14:30', + endTime: '15:30', + } as EventForm; + + const event2 = { + date: '2025-07-01', + startTime: '15:00', + endTime: '16:00', + } as EventForm; + + expect(isOverlapping(event1, event2)).toBe(true); + }); + + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + const event1 = { + date: '2025-07-01', + startTime: '14:30', + endTime: '15:30', + } as EventForm; - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + const event2 = { + date: '2025-07-02', + startTime: '15:00', + endTime: '16:00', + } as EventForm; + + expect(isOverlapping(event1, event2)).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const events = [ + { + id: '1', + date: '2025-07-01', + startTime: '14:30', + endTime: '15:30', + }, + { + id: '2', + date: '2025-07-02', + startTime: '15:00', + endTime: '16:00', + }, + ] as Event[]; + + const newEvent = { + date: '2025-07-01', + startTime: '15:00', + endTime: '15:30', + } as EventForm; + + expect(findOverlappingEvents(newEvent, events)).toEqual([events[0]]); + }); + + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const events = [ + { + id: '1', + date: '2025-07-01', + startTime: '14:30', + endTime: '15:30', + }, + { + id: '2', + date: '2025-07-02', + startTime: '15:00', + endTime: '16:00', + }, + { + id: '3', + date: '2025-07-03', + startTime: '15:00', + endTime: '16:00', + }, + ] as Event[]; + + const newEvent = { + date: '2025-08-01', + startTime: '15:00', + endTime: '16:00', + } as EventForm; - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + expect(findOverlappingEvents(newEvent, events)).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..e4efec7f 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -2,19 +2,183 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + const events = [ + { + date: '2025-07-01', + title: '이벤트 1', + description: '이벤트 1 설명', + location: '이벤트 1 장소', + }, + { + date: '2025-07-01', + title: '이벤트 2', + description: '이벤트 2 설명', + location: '이벤트 2 장소', + }, + ] as Event[]; - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + const filteredEvents = getFilteredEvents(events, '이벤트 2', new Date('2025-07-01'), 'week'); + expect(filteredEvents).toEqual([events[1]]); + }); - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + const events = [ + { + date: '2025-06-05', + title: '이벤트 1', + description: '이벤트 1 설명', + location: '이벤트 1 장소', + }, + { + date: '2025-07-03', + title: '이벤트 2', + description: '이벤트 2 설명', + location: '이벤트 2 장소', + }, + { + date: '2025-08-01', + title: '이벤트 3', + description: '이벤트 3 설명', + location: '이벤트 3 장소', + }, + ] as Event[]; - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + const filteredEvents = getFilteredEvents(events, '이벤트', new Date('2025-07-01'), 'week'); + expect(filteredEvents).toEqual([events[1]]); + }); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + const events = [ + { + date: '2025-06-01', + title: '이벤트 1', + description: '이벤트 1 설명', + location: '이벤트 1 장소', + }, + { + date: '2025-07-01', + title: '이벤트 2', + description: '이벤트 2 설명', + location: '이벤트 2 장소', + }, + { + date: '2025-08-01', + title: '이벤트 3', + description: '이벤트 3 설명', + location: '이벤트 3 장소', + }, + ] as Event[]; - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + const filteredEvents = getFilteredEvents(events, '이벤트', new Date('2025-07-01'), 'month'); + expect(filteredEvents).toEqual([events[1]]); + }); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + const events = [ + { + date: '2025-06-15', + title: '이벤트 1', + description: '이벤트 1 설명', + location: '이벤트 1 장소', + }, + { + date: '2025-06-30', + title: '이벤트 2', + description: '이벤트 2 설명', + location: '이벤트 2 장소', + }, + { + date: '2025-07-01', + title: '이벤트 3', + description: '이벤트 3 설명', + location: '이벤트 3 장소', + }, + { + date: '2025-07-15', + title: '이벤트 4', + description: '이벤트 4 설명', + location: '이벤트 4 장소', + }, + { + date: '2025-07-30', + title: '이벤트 5', + description: '이벤트 5 설명', + location: '이벤트 5 장소', + }, + ] as Event[]; + const filteredEvents = getFilteredEvents(events, '이벤트', new Date('2025-07-03'), 'week'); + expect(filteredEvents).toEqual([events[1], events[2]]); + }); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + it('검색어가 없을 때 모든 이벤트를 반환한다', () => { + const events = [ + { + date: '2025-07-01', + title: '이벤트 1', + description: '이벤트 1 설명', + location: '이벤트 1 장소', + }, + { + date: '2025-07-02', + title: '이벤트 2', + description: '이벤트 2 설명', + location: '이벤트 2 장소', + }, + { + date: '2025-07-03', + title: '이벤트 3', + description: '이벤트 3 설명', + location: '이벤트 3 장소', + }, + ] as Event[]; + + const filteredEvents = getFilteredEvents(events, '', new Date('2025-07-01'), 'week'); + expect(filteredEvents).toEqual(events); + }); + + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const events = [ + { + date: '2025-07-01', + title: 'EVENT 1', + description: 'EVENT 1 설명', + location: 'EVENT 1 장소', + }, + { + date: '2025-07-02', + title: 'EVENT 2', + description: 'EVENT 2 설명', + location: 'EVENT 2 장소', + }, + { + date: '2025-07-03', + title: 'EVENT 3', + description: 'EVENT 3 설명', + location: 'EVENT 3 장소', + }, + ] as Event[]; + const filteredEvents = getFilteredEvents(events, 'event', new Date('2025-07-01'), 'week'); + expect(filteredEvents).toEqual([events[0], events[1], events[2]]); + }); + + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const events = [ + { + date: '2025-07-31', + title: '이벤트 2', + description: '이벤트 2 설명', + location: '이벤트 2 장소', + }, + ] as Event[]; + + const filteredEvents = getFilteredEvents(events, '', new Date('2025-07-01'), 'month'); + expect(filteredEvents).toEqual([events[0]]); + }); + + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const events = [] as Event[]; + const filteredEvents = getFilteredEvents(events, '', new Date('2025-08-01')); + expect(filteredEvents).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..10db5d17 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,37 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + const holidays = fetchHolidays(new Date('2025-10-01')); + expect(holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + }); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + const holidays = fetchHolidays(new Date('2025-11-01')); + expect(holidays).toEqual({}); + }); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + const holidays = fetchHolidays(new Date('2025-01-01')); + expect(holidays).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + + const holidays2 = fetchHolidays(new Date('2025-10-01')); + expect(holidays2).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..cfe6a60f 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -2,15 +2,131 @@ import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + const events = [ + { + id: '1', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + notificationTime: 10, + }, + { + id: '2', + date: '2025-07-01', + startTime: '11:00', + endTime: '12:00', + notificationTime: 10, + }, + { + id: '3', + date: '2025-07-01', + startTime: '12:00', + endTime: '13:00', + notificationTime: 10, + }, + ] as Event[]; - it('이미 알림이 간 이벤트는 제외한다', () => {}); + const upcomingEvents = getUpcomingEvents(events, new Date('2025-07-01 10:50'), []); + expect(upcomingEvents).toEqual([events[1]]); + }); - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + it('이미 알림이 간 이벤트는 제외한다', () => { + const events = [ + { + id: '1', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + notificationTime: 10, + }, + { + id: '2', + date: '2025-07-01', + startTime: '11:00', + endTime: '12:00', + notificationTime: 10, + }, + { + id: '3', + date: '2025-07-01', + startTime: '12:00', + endTime: '13:00', + notificationTime: 10, + }, + ] as Event[]; - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + const upcomingEvents = getUpcomingEvents(events, new Date('2025-07-01 09:55'), ['1']); + expect(upcomingEvents).toEqual([]); + }); + + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + const events = [ + { + id: '1', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + notificationTime: 10, + }, + { + id: '2', + date: '2025-07-01', + startTime: '11:00', + endTime: '12:00', + notificationTime: 10, + }, + { + id: '3', + date: '2025-07-01', + startTime: '12:00', + endTime: '13:00', + notificationTime: 10, + }, + ] as Event[]; + + const upcomingEvents = getUpcomingEvents(events, new Date('2025-07-01 09:00'), []); + expect(upcomingEvents).toEqual([]); + }); + + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const events = [ + { + id: '1', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + notificationTime: 10, + }, + { + id: '2', + date: '2025-07-01', + startTime: '11:00', + endTime: '12:00', + notificationTime: 10, + }, + { + id: '3', + date: '2025-07-01', + startTime: '12:00', + endTime: '13:00', + notificationTime: 10, + }, + ] as Event[]; + + const upcomingEvents = getUpcomingEvents(events, new Date('2025-07-01 13:30'), []); + expect(upcomingEvents).toEqual([]); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const event = { + title: '이벤트 1', + notificationTime: 10, + } as Event; + + const message = createNotificationMessage(event); + expect(message).toBe('10분 후 이벤트 1 일정이 시작됩니다.'); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..e15c9d58 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,52 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('11:00', '09:00'); + expect(result).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('10:00', '10:00'); + expect(result).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + // 불필요한 테스트: 시작 시간이 종료 시간보다 빠른 것은 당연하다. 에러를 반환할 필요가 없다. + it.skip('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + const result = getTimeErrorMessage('09:00', '10:00'); + expect(result).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', '10:00'); + expect(result).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('종료 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('10:00', ''); + expect(result).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', ''); + expect(result).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); }); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 8e419c87..f2c8b22f 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -1,3 +1,4 @@ +import { Event } from '../types'; import { fillZero } from '../utils/dateUtils'; export const assertDate = (date1: Date, date2: Date) => { @@ -10,3 +11,33 @@ export const parseHM = (timestamp: number) => { const m = fillZero(date.getMinutes()); return `${h}:${m}`; }; + +// 공통 테스트 데이터 +export const createMockEvent = (overrides: Partial = {}): Event => ({ + id: '1', + title: '팀 회의', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + ...overrides, +}); + +export const createMockEvents = (): Event[] => [ + createMockEvent(), + createMockEvent({ + id: '2', + title: '점심 약속', + startTime: '12:00', + endTime: '13:00', + description: '팀원들과 점심', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'weekly', interval: 1, endDate: '2025-12-31' }, + notificationTime: 30, + }), +]; diff --git a/src/components/calendar/CalendarView.tsx b/src/components/calendar/CalendarView.tsx new file mode 100644 index 00000000..aba7d9d6 --- /dev/null +++ b/src/components/calendar/CalendarView.tsx @@ -0,0 +1,71 @@ +import ChevronLeft from '@mui/icons-material/ChevronLeft'; +import ChevronRight from '@mui/icons-material/ChevronRight'; +import { IconButton, MenuItem, Select, Stack, Typography } from '@mui/material'; + +import { MonthView } from './MonthView'; +import { WeekView } from './WeekView'; +import { Event } from '../../types'; + +interface CalendarViewProps { + view: 'week' | 'month'; + setView: (view: 'week' | 'month') => void; + currentDate: Date; + navigate: (direction: 'prev' | 'next') => void; + filteredEvents: Event[]; + notifiedEvents: string[]; + holidays: Record; +} + +export function CalendarView({ + view, + setView, + currentDate, + navigate, + filteredEvents, + notifiedEvents, + holidays, +}: CalendarViewProps) { + return ( + + 일정 보기 + + + navigate('prev')}> + + + + navigate('next')}> + + + + + {view === 'week' && ( + + )} + {view === 'month' && ( + + )} + + ); +} diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx new file mode 100644 index 00000000..e18c6c8a --- /dev/null +++ b/src/components/calendar/MonthView.tsx @@ -0,0 +1,118 @@ +import Notifications from '@mui/icons-material/Notifications'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; +import { formatDate, formatMonth, getEventsForDay, getWeeksAtMonth } from '../../utils/dateUtils'; + +interface MonthViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; + holidays: Record; +} + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +export function MonthView({ + currentDate, + filteredEvents, + notifiedEvents, + holidays, +}: MonthViewProps) { + const weeks = getWeeksAtMonth(currentDate); + + const renderEventItem = (event: Event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + }; + + return ( + + {formatMonth(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + {weeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + const dateString = day ? formatDate(currentDate, day) : ''; + const holiday = holidays[dateString]; + + return ( + + {day && ( + <> + + {day} + + {holiday && ( + + {holiday} + + )} + {getEventsForDay(filteredEvents, day).map(renderEventItem)} + + )} + + ); + })} + + ))} + +
+
+
+ ); +} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx new file mode 100644 index 00000000..02c14452 --- /dev/null +++ b/src/components/calendar/WeekView.tsx @@ -0,0 +1,97 @@ +import Notifications from '@mui/icons-material/Notifications'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; +import { formatWeek, getWeekDates } from '../../utils/dateUtils'; + +interface WeekViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; +} + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +export function WeekView({ currentDate, filteredEvents, notifiedEvents }: WeekViewProps) { + const weekDates = getWeekDates(currentDate); + + const renderEventItem = (event: Event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + }; + + return ( + + {formatWeek(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + + {weekDates.map((date) => ( + + + {date.getDate()} + + {filteredEvents + .filter((event) => new Date(event.date).toDateString() === date.toDateString()) + .map(renderEventItem)} + + ))} + + +
+
+
+ ); +} diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts new file mode 100644 index 00000000..da421a27 --- /dev/null +++ b/src/components/calendar/index.ts @@ -0,0 +1,3 @@ +export { CalendarView } from './CalendarView'; +export { MonthView } from './MonthView'; +export { WeekView } from './WeekView'; diff --git a/src/components/dialog/OverlapDialog.tsx b/src/components/dialog/OverlapDialog.tsx new file mode 100644 index 00000000..80485c8a --- /dev/null +++ b/src/components/dialog/OverlapDialog.tsx @@ -0,0 +1,43 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; + +interface OverlapDialogProps { + open: boolean; + onClose: () => void; + overlappingEvents: Event[]; + onConfirm: () => void; +} + +export function OverlapDialog({ open, onClose, overlappingEvents, onConfirm }: OverlapDialogProps) { + return ( + + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +} diff --git a/src/components/dialog/index.ts b/src/components/dialog/index.ts new file mode 100644 index 00000000..09bed373 --- /dev/null +++ b/src/components/dialog/index.ts @@ -0,0 +1 @@ +export { OverlapDialog } from './OverlapDialog'; diff --git a/src/components/event/EventForm.tsx b/src/components/event/EventForm.tsx new file mode 100644 index 00000000..59635d16 --- /dev/null +++ b/src/components/event/EventForm.tsx @@ -0,0 +1,229 @@ +import { + Button, + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import React from 'react'; + +import { Event, EventForm } from '../../types'; +import { getTimeErrorMessage } from '../../utils/timeValidation'; + +interface FormState { + title: string; + date: string; + startTime: string; + endTime: string; + description: string; + location: string; + category: string; + isRepeating: boolean; + repeatType: string; + repeatInterval: number; + repeatEndDate: string; + notificationTime: number; + editingEvent: EventForm | null; +} + +interface FormActions { + setTitle: (title: string) => void; + setDate: (date: string) => void; + setDescription: (description: string) => void; + setLocation: (location: string) => void; + setCategory: (category: string) => void; + setIsRepeating: (isRepeating: boolean) => void; + setNotificationTime: (time: number) => void; + handleStartTimeChange: (e: React.ChangeEvent) => void; + handleEndTimeChange: (e: React.ChangeEvent) => void; + resetForm: () => void; + editEvent: (event: Event) => void; + onSubmit: () => void; +} + +interface FormValidation { + startTimeError: string | null; + endTimeError: string | null; +} + +interface EventFormProps { + formState: FormState; + formActions: FormActions; + formValidation: FormValidation; +} + +const categories = ['업무', '개인', '가족', '기타']; + +const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +export function EventFormComponent({ formState, formActions, formValidation }: EventFormProps) { + const { + title, + date, + startTime, + endTime, + description, + location, + category, + isRepeating, + notificationTime, + editingEvent, + } = formState; + + const { + setTitle, + setDate, + setDescription, + setLocation, + setCategory, + setIsRepeating, + setNotificationTime, + handleStartTimeChange, + handleEndTimeChange, + onSubmit, + } = formActions; + + const { startTimeError, endTimeError } = formValidation; + + return ( + + {editingEvent ? '일정 수정' : '일정 추가'} + + + 제목 + setTitle(e.target.value)} + /> + + + + 날짜 + setDate(e.target.value)} + /> + + + + + 시작 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!startTimeError} + /> + + + + 종료 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!endTimeError} + /> + + + + + + 설명 + setDescription(e.target.value)} + /> + + + + 위치 + setLocation(e.target.value)} + /> + + + + 카테고리 + + + + + setIsRepeating(e.target.checked)} /> + } + label="반복 일정" + /> + + + + 알림 설정 + + + + + + ); +} diff --git a/src/components/event/EventList.tsx b/src/components/event/EventList.tsx new file mode 100644 index 00000000..6d5149f3 --- /dev/null +++ b/src/components/event/EventList.tsx @@ -0,0 +1,117 @@ +import Delete from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import Notifications from '@mui/icons-material/Notifications'; +import { + Box, + FormControl, + FormLabel, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; + +interface EventListProps { + searchTerm: string; + setSearchTerm: (term: string) => void; + filteredEvents: Event[]; + notifiedEvents: string[]; + editEvent: (event: Event) => void; + deleteEvent: (id: string) => void; +} + +const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +export function EventList({ + searchTerm, + setSearchTerm, + filteredEvents, + notifiedEvents, + editEvent, + deleteEvent, +}: EventListProps) { + const renderEventItem = (event: Event) => { + const isNotified = notifiedEvents.includes(event.id); + + return ( + + + + + {isNotified && } + + {event.title} + + + {event.date} + + {event.startTime} - {event.endTime} + + {event.description} + {event.location} + 카테고리: {event.category} + {event.repeat.type !== 'none' && ( + + 반복: {event.repeat.interval} + {event.repeat.type === 'daily' && '일'} + {event.repeat.type === 'weekly' && '주'} + {event.repeat.type === 'monthly' && '월'} + {event.repeat.type === 'yearly' && '년'} + 마다 + {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`} + + )} + + 알림:{' '} + {notificationOptions.find((option) => option.value === event.notificationTime)?.label} + + + + editEvent(event)}> + + + deleteEvent(event.id)}> + + + + + + ); + }; + + return ( + + + 일정 검색 + setSearchTerm(e.target.value)} + /> + + + {filteredEvents.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + filteredEvents.map(renderEventItem) + )} + + ); +} diff --git a/src/components/event/index.ts b/src/components/event/index.ts new file mode 100644 index 00000000..e8c64a75 --- /dev/null +++ b/src/components/event/index.ts @@ -0,0 +1,2 @@ +export { EventFormComponent } from './EventForm'; +export { EventList } from './EventList'; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 00000000..06a17793 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,11 @@ +// Event related components +export * from './event'; + +// Calendar related components +export * from './calendar'; + +// Notification related components +export * from './notification'; + +// Dialog related components +export * from './dialog'; diff --git a/src/components/notification/NotificationToast.tsx b/src/components/notification/NotificationToast.tsx new file mode 100644 index 00000000..db8682ef --- /dev/null +++ b/src/components/notification/NotificationToast.tsx @@ -0,0 +1,41 @@ +import Close from '@mui/icons-material/Close'; +import { Alert, AlertTitle, IconButton, Stack } from '@mui/material'; + +interface Notification { + id: string; + message: string; +} + +interface NotificationToastProps { + notifications: Notification[]; + setNotifications: (updater: (prev: Notification[]) => Notification[]) => void; +} + +export function NotificationToast({ notifications, setNotifications }: NotificationToastProps) { + if (notifications.length === 0) { + return null; + } + + const handleClose = (index: number) => { + setNotifications((prev) => prev.filter((_, i) => i !== index)); + }; + + return ( + + {notifications.map((notification, index) => ( + handleClose(index)}> + + + } + > + {notification.message} + + ))} + + ); +} diff --git a/src/components/notification/index.ts b/src/components/notification/index.ts new file mode 100644 index 00000000..04ee234e --- /dev/null +++ b/src/components/notification/index.ts @@ -0,0 +1 @@ +export { NotificationToast } from './NotificationToast'; diff --git a/src/hooks/useEventOperations.ts b/src/hooks/useEventOperations.ts index 3216cc05..93812e8a 100644 --- a/src/hooks/useEventOperations.ts +++ b/src/hooks/useEventOperations.ts @@ -3,10 +3,14 @@ import { useEffect, useState } from 'react'; import { Event, EventForm } from '../types'; +// 이벤트 관련 CRUD 작업을 제공하는 커스텀 훅 export const useEventOperations = (editing: boolean, onSave?: () => void) => { + // 이벤트 목록 상태 관리 const [events, setEvents] = useState([]); + // 스낵바 알림 함수 const { enqueueSnackbar } = useSnackbar(); + // 이벤트 목록을 서버에서 불러오는 함수 const fetchEvents = async () => { try { const response = await fetch('/api/events'); @@ -14,23 +18,26 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { throw new Error('Failed to fetch events'); } const { events } = await response.json(); - setEvents(events); + setEvents(events); // 상태에 이벤트 목록 저장 } catch (error) { console.error('Error fetching events:', error); - enqueueSnackbar('이벤트 로딩 실패', { variant: 'error' }); + enqueueSnackbar('이벤트 로딩 실패', { variant: 'error' }); // 에러 알림 } }; + // 이벤트를 저장(추가/수정)하는 함수 const saveEvent = async (eventData: Event | EventForm) => { try { let response; if (editing) { + // 수정 모드: PUT 요청 response = await fetch(`/api/events/${(eventData as Event).id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(eventData), }); } else { + // 추가 모드: POST 요청 response = await fetch('/api/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -42,17 +49,18 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { throw new Error('Failed to save event'); } - await fetchEvents(); - onSave?.(); + await fetchEvents(); // 저장 후 이벤트 목록 갱신 + onSave?.(); // 저장 후 콜백 실행(옵션) enqueueSnackbar(editing ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.', { variant: 'success', - }); + }); // 성공 알림 } catch (error) { console.error('Error saving event:', error); - enqueueSnackbar('일정 저장 실패', { variant: 'error' }); + enqueueSnackbar('일정 저장 실패', { variant: 'error' }); // 에러 알림 } }; + // 이벤트를 삭제하는 함수 const deleteEvent = async (id: string) => { try { const response = await fetch(`/api/events/${id}`, { method: 'DELETE' }); @@ -61,23 +69,25 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { throw new Error('Failed to delete event'); } - await fetchEvents(); - enqueueSnackbar('일정이 삭제되었습니다.', { variant: 'info' }); + await fetchEvents(); // 삭제 후 이벤트 목록 갱신 + enqueueSnackbar('일정이 삭제되었습니다.', { variant: 'info' }); // 삭제 성공 알림 } catch (error) { console.error('Error deleting event:', error); - enqueueSnackbar('일정 삭제 실패', { variant: 'error' }); + enqueueSnackbar('일정 삭제 실패', { variant: 'error' }); // 에러 알림 } }; + // 컴포넌트 마운트 시 초기화 함수 async function init() { - await fetchEvents(); - enqueueSnackbar('일정 로딩 완료!', { variant: 'info' }); + await fetchEvents(); // 이벤트 목록 불러오기 + enqueueSnackbar('일정 로딩 완료!', { variant: 'info' }); // 로딩 완료 알림 } + // 컴포넌트가 처음 렌더링될 때 init 실행 useEffect(() => { init(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 훅에서 제공하는 값 및 함수들 반환 return { events, fetchEvents, saveEvent, deleteEvent }; }; diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index be78512c..a4411e13 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -93,7 +93,7 @@ export function isDateInRange(date: Date, rangeStart: Date, rangeEnd: Date): boo const normalizedDate = stripTime(date); const normalizedStart = stripTime(rangeStart); const normalizedEnd = stripTime(rangeEnd); - + console.log(normalizedDate, normalizedStart, normalizedEnd); return normalizedDate >= normalizedStart && normalizedDate <= normalizedEnd; }