From 3abc65e6b0920702884093d456fdec42b691aedb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9C=EC=A0=9C=EA=B2=BD?= Date: Sun, 26 Jul 2026 21:23:01 +0900 Subject: [PATCH] =?UTF-8?q?test:=20metricsToKpis=20=C2=B7=20METRIC=5FREGIS?= =?UTF-8?q?TRY=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vitest 환경 구축 (vitest.config.ts, @/* alias, src/__tests__ 범위 한정) - METRIC_REGISTRY 포맷 함수 검증: 통화(₩ 올림), percent(toFixed2), delta(Math.abs) - metricsToKpis: title/value/trend.direction/trend.value 및 경계값(delta=0, clicks=0) - CI 파이프라인에 unit test 단계 추가 (lint → test → build) --- .github/workflows/ci.yaml | 3 + package.json | 7 +- pnpm-lock.yaml | 223 ++++++++++++++++++ .../__tests__/metricRegistry.test.ts | 149 ++++++++++++ .../dashboard/__tests__/metricsToKpis.test.ts | 115 +++++++++ tsconfig.node.json | 1 + vitest.config.ts | 15 ++ 7 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 src/utils/dashboard/__tests__/metricRegistry.test.ts create mode 100644 src/utils/dashboard/__tests__/metricsToKpis.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e931c3d6..c9ce2845 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,5 +32,8 @@ jobs: - name: Run lint run: pnpm lint + - name: Run unit tests + run: pnpm test + - name: Run build run: pnpm build diff --git a/package.json b/package.json index be5aea9e..c7b18837 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "chromatic": "npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -74,7 +76,8 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.53.0", "vite": "^6.4.1", - "vite-plugin-svgr": "^4.5.0" + "vite-plugin-svgr": "^4.5.0", + "vitest": "^4.1.10" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87a9a970..45b547a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: vite-plugin-svgr: specifier: ^4.5.0 version: 4.5.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.10.13)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) packages: @@ -735,6 +738,9 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -1169,12 +1175,18 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} @@ -1295,21 +1307,50 @@ packages: '@vitest/expect@2.0.5': resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.0.5': resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/spy@2.0.5': resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/utils@2.0.5': resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@yr/monotone-cubic-spline@1.0.3': resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} @@ -1496,6 +1537,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} engines: {node: '>=8'} @@ -1773,6 +1818,9 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1964,6 +2012,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2787,6 +2839,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -2844,6 +2900,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} @@ -3137,6 +3196,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3169,6 +3231,12 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3286,6 +3354,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -3298,6 +3369,10 @@ packages: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} @@ -3480,6 +3555,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -3504,6 +3620,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4082,6 +4203,8 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@storybook/addon-actions@8.6.17(storybook@8.6.17(prettier@3.8.1))': @@ -4538,12 +4661,19 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/crypto-js@4.2.2': {} '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/doctrine@0.0.9': {} '@types/estree-jsx@1.0.5': @@ -4696,6 +4826,23 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + '@vitest/pretty-format@2.0.5': dependencies: tinyrainbow: 1.2.0 @@ -4704,10 +4851,28 @@ snapshots: dependencies: tinyrainbow: 1.2.0 + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@2.0.5': dependencies: tinyspy: 3.0.2 + '@vitest/spy@4.1.10': {} + '@vitest/utils@2.0.5': dependencies: '@vitest/pretty-format': 2.0.5 @@ -4721,6 +4886,12 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@yr/monotone-cubic-spline@1.0.3': {} acorn-jsx@5.3.2(acorn@8.15.0): @@ -4934,6 +5105,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -5243,6 +5416,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -5514,6 +5689,8 @@ snapshots: eventemitter3@5.0.4: {} + expect-type@1.4.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -6420,6 +6597,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.4: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -6489,6 +6668,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + pathval@2.0.1: {} picocolors@1.1.1: {} @@ -6844,6 +7025,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} slice-ansi@7.1.2: @@ -6869,6 +7052,10 @@ snapshots: split2@4.2.0: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -7010,6 +7197,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -7019,6 +7208,8 @@ snapshots: tinyrainbow@1.2.0: {} + tinyrainbow@3.1.0: {} + tinyspy@3.0.2: {} to-regex-range@5.0.1: @@ -7218,6 +7409,33 @@ snapshots: lightningcss: 1.31.1 yaml: 2.8.2 + vitest@4.1.10(@types/node@24.10.13)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.13 + transitivePeerDependencies: + - msw + webpack-virtual-modules@0.6.2: {} which-boxed-primitive@1.1.1: @@ -7265,6 +7483,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: diff --git a/src/utils/dashboard/__tests__/metricRegistry.test.ts b/src/utils/dashboard/__tests__/metricRegistry.test.ts new file mode 100644 index 00000000..ad1e43e1 --- /dev/null +++ b/src/utils/dashboard/__tests__/metricRegistry.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import { + getKpiMetric, + getMetricKpiTitle, + METRIC_REGISTRY, + OVERVIEW_KPI_BINDINGS, +} from "@/utils/dashboard/metricRegistry"; + +describe("METRIC_REGISTRY", () => { + describe("clicks", () => { + it("label이 '클릭수'이다", () => { + expect(METRIC_REGISTRY.clicks.label).toBe("클릭수"); + }); + it("format: 천 단위 콤마 (ko-KR)", () => { + expect(METRIC_REGISTRY.clicks.format(1500)).toBe("1,500"); + }); + it("formatDelta: 양수 → toFixed(2)%", () => { + expect(METRIC_REGISTRY.clicks.formatDelta(0.2)).toBe("0.20%"); + }); + it("formatDelta: 음수 → Math.abs 적용으로 부호 없음", () => { + // formatPercentDelta = (v) => `${Math.abs(v).toFixed(2)}%` + expect(METRIC_REGISTRY.clicks.formatDelta(-0.05)).toBe("0.05%"); + }); + it("formatDelta: 0 → '0.00%'", () => { + expect(METRIC_REGISTRY.clicks.formatDelta(0)).toBe("0.00%"); + }); + }); + + describe("impressions", () => { + it("label이 '노출수'이다", () => { + expect(METRIC_REGISTRY.impressions.label).toBe("노출수"); + }); + it("format: 천 단위 콤마", () => { + expect(METRIC_REGISTRY.impressions.format(50000)).toBe("50,000"); + }); + it("formatDelta: 음수 → 절댓값", () => { + expect(METRIC_REGISTRY.impressions.formatDelta(-3.0)).toBe("3.00%"); + }); + }); + + describe("conversion", () => { + it("label이 'CVR(전환율)'이다", () => { + expect(METRIC_REGISTRY.conversion.label).toBe("CVR(전환율)"); + }); + it("kpiLabel이 '전환율'이다", () => { + expect(METRIC_REGISTRY.conversion.kpiLabel).toBe("전환율"); + }); + it("format: toFixed(2)%", () => { + expect(METRIC_REGISTRY.conversion.format(2.5)).toBe("2.50%"); + }); + it("formatDelta: 음수 → 절댓값", () => { + expect(METRIC_REGISTRY.conversion.formatDelta(-1.5)).toBe("1.50%"); + }); + }); + + describe("roas", () => { + it("label이 'ROAS'이다", () => { + expect(METRIC_REGISTRY.roas.label).toBe("ROAS"); + }); + it("format: toFixed(2)%", () => { + expect(METRIC_REGISTRY.roas.format(150.75)).toBe("150.75%"); + }); + it("formatDelta: 음수 → 절댓값", () => { + expect(METRIC_REGISTRY.roas.formatDelta(-1.25)).toBe("1.25%"); + }); + }); + + describe("spend", () => { + it("label이 '비용(지출)'이다", () => { + expect(METRIC_REGISTRY.spend.label).toBe("비용(지출)"); + }); + it("format: Math.round 후 ₩ + 천 단위 콤마", () => { + expect(METRIC_REGISTRY.spend.format(1500000.7)).toBe("₩1,500,001"); + }); + it("format: 정수 → ₩ + 천 단위 콤마", () => { + expect(METRIC_REGISTRY.spend.format(1500000)).toBe("₩1,500,000"); + }); + it("format: 소수점 .4 이하 → 내림", () => { + expect(METRIC_REGISTRY.spend.format(999.4)).toBe("₩999"); + }); + }); + + describe("ctr", () => { + it("format: toFixed(2)%", () => { + expect(METRIC_REGISTRY.ctr.format(3.14)).toBe("3.14%"); + }); + }); + + describe("cpa", () => { + it("format: Math.round 후 ₩ + 천 단위 콤마", () => { + expect(METRIC_REGISTRY.cpa.format(50000.4)).toBe("₩50,000"); + }); + }); + + describe("revenue / adSpend", () => { + it("revenue format: ₩ 통화 포맷", () => { + expect(METRIC_REGISTRY.revenue.format(2000000)).toBe("₩2,000,000"); + }); + it("adSpend format: ₩ 통화 포맷", () => { + expect(METRIC_REGISTRY.adSpend.format(300000)).toBe("₩300,000"); + }); + }); +}); + +describe("getKpiMetric", () => { + it("반환값에 format, formatDelta 함수가 있다", () => { + const metric = getKpiMetric("clicks"); + expect(typeof metric.format).toBe("function"); + expect(typeof metric.formatDelta).toBe("function"); + }); + + it("conversion 반환 시 format이 percent 포맷터이다", () => { + const metric = getKpiMetric("conversion"); + expect(metric.format(5.0)).toBe("5.00%"); + }); +}); + +describe("getMetricKpiTitle", () => { + it("conversion → kpiLabel '전환율'", () => { + expect(getMetricKpiTitle("conversion")).toBe("전환율"); + }); + it("clicks → label '클릭수'", () => { + expect(getMetricKpiTitle("clicks")).toBe("클릭수"); + }); + it("impressions → label '노출수'", () => { + expect(getMetricKpiTitle("impressions")).toBe("노출수"); + }); + it("roas → label 'ROAS'", () => { + expect(getMetricKpiTitle("roas")).toBe("ROAS"); + }); +}); + +describe("OVERVIEW_KPI_BINDINGS", () => { + it("4개 항목이다", () => { + expect(OVERVIEW_KPI_BINDINGS).toHaveLength(4); + }); + it("순서: clicks → impressions → conversion → roas", () => { + const keys = OVERVIEW_KPI_BINDINGS.map((b) => b.registryKey); + expect(keys).toEqual(["clicks", "impressions", "conversion", "roas"]); + }); + it("각 항목에 registryKey, valueField, deltaField가 있다", () => { + for (const binding of OVERVIEW_KPI_BINDINGS) { + expect(binding).toHaveProperty("registryKey"); + expect(binding).toHaveProperty("valueField"); + expect(binding).toHaveProperty("deltaField"); + } + }); +}); diff --git a/src/utils/dashboard/__tests__/metricsToKpis.test.ts b/src/utils/dashboard/__tests__/metricsToKpis.test.ts new file mode 100644 index 00000000..aed79452 --- /dev/null +++ b/src/utils/dashboard/__tests__/metricsToKpis.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import type { IMetricsResponse } from "@/types/dashboard/common"; + +import { metricsToKpis } from "@/utils/dashboard/metricsToKpis"; + +const baseMetrics: IMetricsResponse = { + clicks: 1000, + clickChangeRate: 0.2, + impressions: 50000, + impressionChangeRate: -0.05, + conversion: 2.5, + cvrChangeRate: 0.1, + ROAS: 150.75, + ROASChangeRate: -1.25, +}; + +describe("metricsToKpis", () => { + it("OVERVIEW_KPI_BINDINGS 순서대로 4개 KPI를 반환한다", () => { + expect(metricsToKpis(baseMetrics)).toHaveLength(4); + }); + + describe("clicks KPI (index 0)", () => { + it("title이 '클릭수'이다", () => { + const [kpi] = metricsToKpis(baseMetrics); + expect(kpi.title).toBe("클릭수"); + }); + it("value가 천 단위 콤마 포맷이다", () => { + const [kpi] = metricsToKpis(baseMetrics); + expect(kpi.value).toBe("1,000"); + }); + it("양수 delta → direction 'up'", () => { + const [kpi] = metricsToKpis(baseMetrics); + expect(kpi.trend?.direction).toBe("up"); + }); + it("trend.value는 절댓값 퍼센트 포맷이다", () => { + const [kpi] = metricsToKpis(baseMetrics); + expect(kpi.trend?.value).toBe("0.20%"); + }); + }); + + describe("impressions KPI (index 1)", () => { + it("title이 '노출수'이다", () => { + const result = metricsToKpis(baseMetrics); + expect(result[1].title).toBe("노출수"); + }); + it("value가 천 단위 콤마 포맷이다", () => { + const result = metricsToKpis(baseMetrics); + expect(result[1].value).toBe("50,000"); + }); + it("음수 delta → direction 'down'", () => { + const result = metricsToKpis(baseMetrics); + expect(result[1].trend?.direction).toBe("down"); + }); + it("trend.value는 Math.abs 적용으로 부호 없음", () => { + const result = metricsToKpis(baseMetrics); + expect(result[1].trend?.value).toBe("0.05%"); + }); + }); + + describe("conversion KPI (index 2)", () => { + it("title이 '전환율'이다 (kpiLabel 우선)", () => { + const result = metricsToKpis(baseMetrics); + expect(result[2].title).toBe("전환율"); + }); + it("value가 toFixed(2)% 포맷이다", () => { + const result = metricsToKpis(baseMetrics); + expect(result[2].value).toBe("2.50%"); + }); + it("양수 delta → direction 'up'", () => { + const result = metricsToKpis(baseMetrics); + expect(result[2].trend?.direction).toBe("up"); + }); + }); + + describe("roas KPI (index 3)", () => { + it("title이 'ROAS'이다", () => { + const result = metricsToKpis(baseMetrics); + expect(result[3].title).toBe("ROAS"); + }); + it("value가 toFixed(2)% 포맷이다", () => { + const result = metricsToKpis(baseMetrics); + expect(result[3].value).toBe("150.75%"); + }); + it("음수 delta → direction 'down'", () => { + const result = metricsToKpis(baseMetrics); + expect(result[3].trend?.direction).toBe("down"); + }); + it("trend.value는 절댓값 포맷 (1.25%)", () => { + const result = metricsToKpis(baseMetrics); + expect(result[3].trend?.value).toBe("1.25%"); + }); + }); + + describe("경계값", () => { + it("delta = 0 → direction 'up' (>= 0 조건)", () => { + const metrics = { ...baseMetrics, clickChangeRate: 0 }; + const [kpi] = metricsToKpis(metrics); + expect(kpi.trend?.direction).toBe("up"); + expect(kpi.trend?.value).toBe("0.00%"); + }); + + it("clicks = 0 → value '0'", () => { + const metrics = { ...baseMetrics, clicks: 0 }; + const [kpi] = metricsToKpis(metrics); + expect(kpi.value).toBe("0"); + }); + + it("ROAS 소수점 2자리 초과 → toFixed(2) 반올림", () => { + const metrics = { ...baseMetrics, ROAS: 100.555 }; + const result = metricsToKpis(metrics); + expect(result[3].value).toBe("100.56%"); + }); + }); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index 2078de36..4a694cae 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -29,6 +29,7 @@ }, "include": [ "vite.config.ts", + "vitest.config.ts", "svg.d.ts", "playwright.config.ts", "tests/**/*.ts" diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..9808bb6f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "path"; + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +});