From f5c6591310bad2cc68324da079a6e64cd68a2167 Mon Sep 17 00:00:00 2001 From: ZProger Date: Wed, 9 Sep 2026 00:52:27 +0300 Subject: [PATCH] =?UTF-8?q?test:=20=D1=8E=D0=BD=D0=B8=D1=82-=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=BE-=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D1=84=D0=B5=D0=BB=D1=8F=20+=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D1=8F=201.1.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest для чистой логики: lib/format.ts (заодно поймал баг в trimTrailingZeros на "-0.000") и расчёт разбивки по риску/монете/сети на вкладке Crypto — вынес их из компонентов в отдельные тестируемые функции. Тесты подключены в CI. Версия приложения обновлена до 1.1.6. --- .github/workflows/ci.yml | 5 + backend/app/core/config.py | 2 +- frontend/package-lock.json | 828 +++++++++++++++++- frontend/package.json | 9 +- .../crypto/CryptoAllocationBody.test.ts | 40 + .../crypto/CryptoAllocationBody.tsx | 6 +- .../CryptoNetworkAllocationBody.test.ts | 59 ++ .../crypto/CryptoRiskAllocationBody.test.ts | 83 ++ .../crypto/CryptoRiskAllocationBody.tsx | 75 +- frontend/src/lib/format.test.ts | 155 ++++ frontend/src/lib/format.ts | 2 +- frontend/src/test/cryptoFixtures.ts | 33 + frontend/vitest.config.ts | 21 + 13 files changed, 1279 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/crypto/CryptoAllocationBody.test.ts create mode 100644 frontend/src/components/crypto/CryptoNetworkAllocationBody.test.ts create mode 100644 frontend/src/components/crypto/CryptoRiskAllocationBody.test.ts create mode 100644 frontend/src/lib/format.test.ts create mode 100644 frontend/src/test/cryptoFixtures.ts create mode 100644 frontend/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feb01df..d862416 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,3 +64,8 @@ jobs: # and a type error a PR might introduce, same as `npm run build` # locally before shipping a change. - run: npm run build + # Vitest over the pure-logic modules (lib/format.ts, the Crypto tab's + # allocation math) — kept in the same job rather than a new one so a + # renamed job doesn't silently stop matching branch protection's + # required status checks. + - run: npm run test diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 50823fb..ac0d37c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict # title, /api/health (which the frontend reads to show it in Settings), and # embedded in exported backups so an old file can be told apart from a # current one. -APP_VERSION = "1.1.5" +APP_VERSION = "1.1.6" class Settings(BaseSettings): diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c51e542..ed75964 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "aurum-frontend", - "version": "0.1.0", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aurum-frontend", - "version": "0.1.0", + "version": "1.1.5", "dependencies": { "@tanstack/react-query": "^5.101.4", "clsx": "^2.1.1", @@ -23,9 +23,215 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^30.0.1", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", - "vite": "^8.2.1" + "vite": "^8.2.1", + "vitest": "^5.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, "node_modules/@jridgewell/gen-mapping": { @@ -699,6 +905,17 @@ "react": "^18 || ^19" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -762,6 +979,20 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.2.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", @@ -1164,6 +1395,84 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1186,6 +1495,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1314,6 +1637,42 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -1344,6 +1703,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.50.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", @@ -1355,12 +1734,32 @@ "tests/types" ] }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1401,6 +1800,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/immer": { "version": "11.1.16", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz", @@ -1420,6 +1832,13 @@ "node": ">=12" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1430,6 +1849,47 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1703,6 +2163,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-react": { "version": "1.31.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", @@ -1722,6 +2192,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1741,6 +2218,33 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1749,9 +2253,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -1790,6 +2294,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1924,6 +2438,16 @@ "redux": "^5.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -1963,6 +2487,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1975,6 +2512,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1985,6 +2529,27 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -2022,6 +2587,26 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2039,6 +2624,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -2074,6 +2705,16 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -2462,6 +3103,181 @@ "type": "opencollective", "url": "https://opencollective.com/parcel" } + }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.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 + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 6018dad..8991421 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,13 +1,14 @@ { "name": "aurum-frontend", "private": true, - "version": "1.1.5", + "version": "1.1.6", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "lint": "tsc -b --noEmit" + "lint": "tsc -b --noEmit", + "test": "vitest run" }, "dependencies": { "@tanstack/react-query": "^5.101.4", @@ -25,8 +26,10 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^30.0.1", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", - "vite": "^8.2.1" + "vite": "^8.2.1", + "vitest": "^5.0.0" } } diff --git a/frontend/src/components/crypto/CryptoAllocationBody.test.ts b/frontend/src/components/crypto/CryptoAllocationBody.test.ts new file mode 100644 index 0000000..8fb83ef --- /dev/null +++ b/frontend/src/components/crypto/CryptoAllocationBody.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { makeHolding } from "@/test/cryptoFixtures"; +import { buildSlices } from "./CryptoAllocationBody"; + +describe("buildSlices (per-coin allocation)", () => { + it("keys each slice by asset_id, one per holding", () => { + const holdings = [makeHolding({ asset_id: 7, name: "Bitcoin", symbol: "BTC", value: "100" })]; + const slices = buildSlices(holdings); + expect(slices).toEqual([{ key: "7", name: "Bitcoin", symbol: "BTC", amount: 100, color: "var(--series-1)" }]); + }); + + it("excludes unpriced and exactly-zero holdings", () => { + const holdings = [makeHolding({ value: null }), makeHolding({ value: "0" })]; + expect(buildSlices(holdings)).toEqual([]); + }); + + it("sorts slices by amount descending", () => { + const holdings = [ + makeHolding({ symbol: "SMALL", value: "10" }), + makeHolding({ symbol: "BIG", value: "90" }), + ]; + expect(buildSlices(holdings).map((s) => s.symbol)).toEqual(["BIG", "SMALL"]); + }); + + it("folds every coin past the top 7 into one 'Other' slice", () => { + const holdings = Array.from({ length: 9 }, (_, i) => makeHolding({ symbol: `C${i}`, value: String(9 - i) })); + const slices = buildSlices(holdings); + expect(slices).toHaveLength(8); + const other = slices.find((s) => s.key === "other")!; + expect(other.name).toBe("Other"); + expect(other.symbol).toBeNull(); + // The two smallest holdings: value 2 and value 1. + expect(other.amount).toBe(3); + expect(other.color).toBe("var(--series-other)"); + }); + + it("returns nothing at all when every holding is unpriced", () => { + expect(buildSlices([makeHolding({ value: null })])).toEqual([]); + }); +}); diff --git a/frontend/src/components/crypto/CryptoAllocationBody.tsx b/frontend/src/components/crypto/CryptoAllocationBody.tsx index 26fa716..dc795f8 100644 --- a/frontend/src/components/crypto/CryptoAllocationBody.tsx +++ b/frontend/src/components/crypto/CryptoAllocationBody.tsx @@ -26,7 +26,7 @@ const SERIES_COLORS = [ const OTHER_COLOR = "var(--series-other)"; const MAX_SLICES = SERIES_COLORS.length; -interface Slice { +export interface Slice { key: string; name: string; symbol: string | null; @@ -34,7 +34,9 @@ interface Slice { color: string; } -function buildSlices(holdings: CryptoHolding[]): Slice[] { +// Exported for direct unit testing (see CryptoAllocationBody.test.ts) — +// the donut/table JSX below isn't worth rendering just to check the math. +export function buildSlices(holdings: CryptoHolding[]): Slice[] { const priced = holdings .filter((h) => h.value !== null && Number(h.value) > 0) .map((h) => ({ key: String(h.asset_id), name: h.name, symbol: h.symbol, amount: Number(h.value) })) diff --git a/frontend/src/components/crypto/CryptoNetworkAllocationBody.test.ts b/frontend/src/components/crypto/CryptoNetworkAllocationBody.test.ts new file mode 100644 index 0000000..f892b22 --- /dev/null +++ b/frontend/src/components/crypto/CryptoNetworkAllocationBody.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { makeHolding } from "@/test/cryptoFixtures"; +import { buildSlices } from "./CryptoNetworkAllocationBody"; + +const UNSET = "No network set"; + +describe("buildSlices (network allocation)", () => { + it("groups holdings by network, summing their value", () => { + const holdings = [ + makeHolding({ network: "Ethereum", value: "100" }), + makeHolding({ network: "Ethereum", value: "50" }), + makeHolding({ network: "Tron", value: "30" }), + ]; + const slices = buildSlices(holdings, UNSET); + const byLabel = Object.fromEntries(slices.map((s) => [s.label, s.amount])); + expect(byLabel["Ethereum"]).toBe(150); + expect(byLabel["Tron"]).toBe(30); + }); + + it("folds holdings with no network into one unset bucket instead of dropping them", () => { + const holdings = [makeHolding({ network: null, value: "40" }), makeHolding({ network: null, value: "10" })]; + const slices = buildSlices(holdings, UNSET); + expect(slices).toHaveLength(1); + expect(slices[0].label).toBe(UNSET); + expect(slices[0].amount).toBe(50); + }); + + it("excludes unpriced holdings entirely", () => { + const holdings = [makeHolding({ network: "Ethereum", value: null })]; + expect(buildSlices(holdings, UNSET)).toEqual([]); + }); + + it("sorts slices by amount descending", () => { + const holdings = [ + makeHolding({ network: "Small", value: "10" }), + makeHolding({ network: "Big", value: "90" }), + ]; + const slices = buildSlices(holdings, UNSET); + expect(slices.map((s) => s.label)).toEqual(["Big", "Small"]); + }); + + it("assigns each of the first 7 slices its own distinct color", () => { + const holdings = Array.from({ length: 7 }, (_, i) => makeHolding({ network: `Net${i}`, value: String(10 - i) })); + const slices = buildSlices(holdings, UNSET); + expect(slices).toHaveLength(7); + expect(new Set(slices.map((s) => s.color)).size).toBe(7); + }); + + it("folds every network past the top 7 into one '__other__' slice", () => { + // 9 distinct networks — 7 keep their own slice, 2 fold into "other". + const holdings = Array.from({ length: 9 }, (_, i) => makeHolding({ network: `Net${i}`, value: String(9 - i) })); + const slices = buildSlices(holdings, UNSET); + expect(slices).toHaveLength(8); // 7 top + 1 "other" + const other = slices.find((s) => s.key === "__other__")!; + expect(other).toBeDefined(); + // The two smallest: Net7 (value 2) and Net8 (value 1). + expect(other.amount).toBe(3); + }); +}); diff --git a/frontend/src/components/crypto/CryptoRiskAllocationBody.test.ts b/frontend/src/components/crypto/CryptoRiskAllocationBody.test.ts new file mode 100644 index 0000000..1a1bc34 --- /dev/null +++ b/frontend/src/components/crypto/CryptoRiskAllocationBody.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { makeHolding } from "@/test/cryptoFixtures"; +import { computeRiskTiers } from "./CryptoRiskAllocationBody"; + +describe("computeRiskTiers", () => { + it("always returns exactly the three tiers, in low/medium/high order", () => { + const tiers = computeRiskTiers([]); + expect(tiers.map((t) => t.level)).toEqual(["low", "medium", "high"]); + }); + + it("splits total portfolio value by risk_level", () => { + const holdings = [ + makeHolding({ symbol: "BTC", risk_level: "low", value: "600" }), + makeHolding({ symbol: "ETH", risk_level: "medium", value: "300" }), + makeHolding({ symbol: "SHIB", risk_level: "high", value: "100" }), + ]; + const tiers = computeRiskTiers(holdings); + const byLevel = Object.fromEntries(tiers.map((t) => [t.level, t])); + + expect(byLevel.low.value).toBe(600); + expect(byLevel.low.percent).toBeCloseTo(60); + expect(byLevel.medium.percent).toBeCloseTo(30); + expect(byLevel.high.percent).toBeCloseTo(10); + }); + + it("sums multiple holdings in the same tier instead of overwriting", () => { + const holdings = [ + makeHolding({ symbol: "SHIB", risk_level: "high", value: "100" }), + makeHolding({ symbol: "PEPE", risk_level: "high", value: "50" }), + ]; + const high = computeRiskTiers(holdings).find((t) => t.level === "high")!; + expect(high.value).toBe(150); + expect(high.items).toHaveLength(2); + }); + + it("excludes unpriced holdings from every percentage, not just their own tier", () => { + const holdings = [ + makeHolding({ symbol: "BTC", risk_level: "low", value: "100" }), + makeHolding({ symbol: "UNPRICED", risk_level: "low", value: null }), + ]; + const low = computeRiskTiers(holdings).find((t) => t.level === "low")!; + expect(low.value).toBe(100); + expect(low.percent).toBe(100); + expect(low.items).toHaveLength(1); + }); + + it("sorts a tier's items by value descending", () => { + const holdings = [ + makeHolding({ symbol: "SMALL", risk_level: "high", value: "10" }), + makeHolding({ symbol: "BIG", risk_level: "high", value: "90" }), + ]; + const high = computeRiskTiers(holdings).find((t) => t.level === "high")!; + expect(high.items.map((i) => i.symbol)).toEqual(["BIG", "SMALL"]); + }); + + it("computes each item's percent of its own tier, not of the whole portfolio", () => { + const holdings = [ + makeHolding({ symbol: "BIG", risk_level: "high", value: "90" }), + makeHolding({ symbol: "SMALL", risk_level: "high", value: "10" }), + makeHolding({ symbol: "OTHER_TIER", risk_level: "low", value: "900" }), + ]; + const high = computeRiskTiers(holdings).find((t) => t.level === "high")!; + // 90/100 and 10/100 of the *high tier's* 100, not of the 1000 total. + expect(high.items.find((i) => i.symbol === "BIG")!.percent).toBeCloseTo(90); + expect(high.items.find((i) => i.symbol === "SMALL")!.percent).toBeCloseTo(10); + }); + + it("leaves an empty tier at 0%, not NaN, when nothing is held in it", () => { + const holdings = [makeHolding({ risk_level: "high", value: "100" })]; + const low = computeRiskTiers(holdings).find((t) => t.level === "low")!; + expect(low.value).toBe(0); + expect(low.percent).toBe(0); + expect(low.items).toEqual([]); + }); + + it("leaves every tier at 0% when nothing is priced at all, instead of dividing by zero", () => { + const holdings = [makeHolding({ risk_level: "high", value: null })]; + for (const tier of computeRiskTiers(holdings)) { + expect(tier.percent).toBe(0); + expect(Number.isNaN(tier.percent)).toBe(false); + } + }); +}); diff --git a/frontend/src/components/crypto/CryptoRiskAllocationBody.tsx b/frontend/src/components/crypto/CryptoRiskAllocationBody.tsx index 8209eef..7cde30c 100644 --- a/frontend/src/components/crypto/CryptoRiskAllocationBody.tsx +++ b/frontend/src/components/crypto/CryptoRiskAllocationBody.tsx @@ -8,16 +8,60 @@ interface CryptoRiskAllocationBodyProps { hidden: boolean; } -// Same status-color convention as NetWorthPage's own RiskAllocationCard -// (low reads as "good", high as "critical") — not a categorical hue, so -// hardcoded here rather than threaded through from the backend. +// Same traffic-light colors as the holdings table's own RiskLevelPicker +// (green/orange/red) — kept in sync so a coin's risk reads identically in +// both places on the Crypto tab. --series-4 is the dataviz skill's +// validated yellow/orange slot. const TIER_COLOR: Record = { low: "var(--success)", - medium: "var(--text-muted)", + medium: "var(--series-4)", high: "var(--danger)", }; const RISK_LEVELS: RiskLevel[] = ["low", "medium", "high"]; +export interface RiskTierItem { + symbol: string; + name: string; + value: number; + percent: number; +} + +export interface RiskTier { + level: RiskLevel; + value: number; + percent: number; + items: RiskTierItem[]; +} + +/** Groups priced holdings into the three RiskLevel tiers with each tier's + * share of total portfolio value, and each item's share within its own + * tier — pulled out of the component so it's testable without rendering + * (see format.test.ts-style coverage in CryptoRiskAllocationBody.test.ts). */ +export function computeRiskTiers(holdings: CryptoHolding[]): RiskTier[] { + // Unpriced holdings (CoinGecko never successfully priced them yet) can't + // contribute a % of portfolio value — same "best data on hand" principle + // the rest of the Crypto tab already follows for a null value/price. + const priced = holdings.filter((h) => h.value !== null); + const totalValue = priced.reduce((sum, h) => sum + Number(h.value), 0); + + return RISK_LEVELS.map((level) => { + const items = priced + .filter((h) => h.risk_level === level) + .map((h) => ({ symbol: h.symbol, name: h.name, value: Number(h.value) })) + .sort((a, b) => b.value - a.value); + const tierValue = items.reduce((sum, item) => sum + item.value, 0); + return { + level, + value: tierValue, + percent: totalValue > 0 ? (tierValue / totalValue) * 100 : 0, + items: items.map((item) => ({ + ...item, + percent: tierValue > 0 ? (item.value / tierValue) * 100 : 0, + })), + }; + }); +} + /** The "Risk levels" tab's content inside CryptoOverviewCard — same * three-tier layout as NetWorthPage's own RiskAllocationCard, but scoped to * crypto holdings only (not the whole net worth), so "am I overweight on @@ -34,28 +78,7 @@ export function CryptoRiskAllocationBody({ holdings, isLoading, hidden }: Crypto return

{t("crypto.empty")}

; } - // Unpriced holdings (CoinGecko never successfully priced them yet) can't - // contribute a % of portfolio value — same "best data on hand" principle - // the rest of the Crypto tab already follows for a null value/price. - const priced = holdings.filter((h) => h.value !== null); - const totalValue = priced.reduce((sum, h) => sum + Number(h.value), 0); - - const tiers = RISK_LEVELS.map((level) => { - const items = priced - .filter((h) => h.risk_level === level) - .map((h) => ({ symbol: h.symbol, name: h.name, value: Number(h.value) })) - .sort((a, b) => b.value - a.value); - const tierValue = items.reduce((sum, item) => sum + item.value, 0); - return { - level, - value: tierValue, - percent: totalValue > 0 ? (tierValue / totalValue) * 100 : 0, - items: items.map((item) => ({ - ...item, - percent: tierValue > 0 ? (item.value / tierValue) * 100 : 0, - })), - }; - }); + const tiers = computeRiskTiers(holdings); return (
diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts new file mode 100644 index 0000000..5c26e67 --- /dev/null +++ b/frontend/src/lib/format.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { setLanguage } from "@/lib/i18n"; +import { + formatCryptoAmount, + formatCurrency, + formatSignedCurrency, + formatTransactionDate, + getIntlLocale, + getMonthLabels, + maskAmount, + pluralizeRu, + trimTrailingZeros, +} from "@/lib/format"; + +describe("getIntlLocale", () => { + it("maps ru to ru-RU and en to en-US", () => { + expect(getIntlLocale("ru")).toBe("ru-RU"); + expect(getIntlLocale("en")).toBe("en-US"); + }); +}); + +describe("formatCurrency", () => { + beforeEach(() => setLanguage("en")); + + it("keeps unambiguous currency symbols compact, not spelled out", () => { + // The exact regression this suite exists to catch: currencyDisplay + // "code" would turn this into "USD 614" — see lib/format.ts's + // createCurrencyFormatter docstring and the PR #18 review that flagged it. + expect(formatCurrency(614, "USD")).toBe("$614"); + }); + + it("drops the region prefix on ambiguous symbols (CNY, HKD) instead of spelling out the code", () => { + const cny = formatCurrency(614, "CNY"); + const hkd = formatCurrency(614, "HKD"); + expect(cny).not.toContain("CN¥"); + expect(cny).not.toContain("CNY"); + expect(hkd).not.toContain("HK$"); + expect(hkd).not.toContain("HKD"); + }); + + it("rounds to whole units", () => { + expect(formatCurrency(614.5, "USD")).toBe("$615"); + expect(formatCurrency(614.4, "USD")).toBe("$614"); + }); + + it("accepts a numeric string the same as a number", () => { + expect(formatCurrency("614", "USD")).toBe(formatCurrency(614, "USD")); + }); +}); + +describe("formatCryptoAmount", () => { + beforeEach(() => setLanguage("en")); + + it("uses 2 decimals once the value reaches 1", () => { + expect(formatCryptoAmount(1234.5678, "USD")).toBe("$1,234.57"); + }); + + it("uses 2 decimals for exactly zero", () => { + expect(formatCryptoAmount(0, "USD")).toBe("$0.00"); + }); + + it("scales precision for sub-cent values so they don't round away to $0.00", () => { + // 8 leading zeros after the decimal point + 4 digits of real precision. + expect(formatCryptoAmount(0.000000006894, "USD")).toBe("$0.000000006894"); + }); +}); + +describe("formatSignedCurrency", () => { + beforeEach(() => setLanguage("en")); + + it("prefixes a plus sign on positive amounts", () => { + expect(formatSignedCurrency(100, "USD")).toBe("+$100"); + }); + + it("leaves the formatter's own minus sign on negative amounts, no double sign", () => { + expect(formatSignedCurrency(-100, "USD")).toBe("-$100"); + }); + + it("adds no sign for zero", () => { + expect(formatSignedCurrency(0, "USD")).toBe("$0"); + }); +}); + +describe("maskAmount", () => { + it("replaces the formatted value when hidden", () => { + expect(maskAmount("$1,234", true)).toBe("••••"); + }); + + it("passes the formatted value through when not hidden", () => { + expect(maskAmount("$1,234", false)).toBe("$1,234"); + }); +}); + +describe("trimTrailingZeros", () => { + it("strips a wei-level all-zero fractional part down to the integer", () => { + expect(trimTrailingZeros("3.000000000000000000")).toBe("3"); + }); + + it("trims only the trailing zeros, keeping real precision", () => { + expect(trimTrailingZeros("3.140000")).toBe("3.14"); + }); + + it("leaves a value with no decimal point untouched", () => { + expect(trimTrailingZeros("42")).toBe("42"); + }); + + it("collapses an all-zero fractional part to a bare 0, not empty or a dangling minus", () => { + expect(trimTrailingZeros("0.000")).toBe("0"); + expect(trimTrailingZeros("-0.000")).toBe("0"); + }); +}); + +describe("getMonthLabels", () => { + it("returns 12 labels for each language", () => { + expect(getMonthLabels("en")).toHaveLength(12); + expect(getMonthLabels("ru")).toHaveLength(12); + }); + + it("starts January in both languages", () => { + expect(getMonthLabels("en")[0]).toBe("Jan"); + expect(getMonthLabels("ru")[0]).toBe("Янв"); + }); +}); + +describe("formatTransactionDate", () => { + it("omits the year by default", () => { + expect(formatTransactionDate("2026-08-29", false)).toBe(getIntlLocale("en") === "en-US" ? "Aug 29" : "Aug 29"); + }); + + it("includes the year when asked", () => { + expect(formatTransactionDate("2026-08-29", true)).toContain("2026"); + }); +}); + +describe("pluralizeRu", () => { + it("picks the singular form for 1 (and 21, 31, ... but not 11)", () => { + expect(pluralizeRu(1, "актив", "актива", "активов")).toBe("актив"); + expect(pluralizeRu(21, "актив", "актива", "активов")).toBe("актив"); + expect(pluralizeRu(11, "актив", "актива", "активов")).not.toBe("актив"); + }); + + it("picks the few form for 2-4 (and 22-24, but not 12-14)", () => { + expect(pluralizeRu(2, "актив", "актива", "активов")).toBe("актива"); + expect(pluralizeRu(4, "актив", "актива", "активов")).toBe("актива"); + expect(pluralizeRu(22, "актив", "актива", "активов")).toBe("актива"); + expect(pluralizeRu(12, "актив", "актива", "активов")).toBe("активов"); + }); + + it("picks the many form for 0, 5-20, and 11-14", () => { + expect(pluralizeRu(0, "актив", "актива", "активов")).toBe("активов"); + expect(pluralizeRu(5, "актив", "актива", "активов")).toBe("активов"); + expect(pluralizeRu(11, "актив", "актива", "активов")).toBe("активов"); + expect(pluralizeRu(14, "актив", "актива", "активов")).toBe("активов"); + }); +}); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 1763b2c..4536448 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -82,7 +82,7 @@ export function maskAmount(formatted: string, hidden: boolean): string { export function trimTrailingZeros(value: string): string { if (!value.includes(".")) return value; const trimmed = value.replace(/0+$/, "").replace(/\.$/, ""); - return trimmed === "" || trimmed === "-" ? "0" : trimmed; + return trimmed === "" || trimmed === "-" || trimmed === "-0" ? "0" : trimmed; } const MONTH_LABELS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const; diff --git a/frontend/src/test/cryptoFixtures.ts b/frontend/src/test/cryptoFixtures.ts new file mode 100644 index 0000000..2e13330 --- /dev/null +++ b/frontend/src/test/cryptoFixtures.ts @@ -0,0 +1,33 @@ +import type { CryptoHolding } from "@/types"; + +let nextAssetId = 1; + +/** Minimal valid CryptoHolding for allocation-math tests — every field the + * type requires, with sane defaults so a test only has to override what it + * actually cares about (symbol, value, risk_level, network, ...). */ +export function makeHolding(overrides: Partial = {}): CryptoHolding { + const id = overrides.asset_id ?? nextAssetId++; + return { + asset_id: id, + portfolio_id: 1, + coingecko_id: `coin-${id}`, + symbol: `C${id}`, + name: `Coin ${id}`, + thumb_url: null, + risk_level: "high", + network: null, + quantity: "1", + avg_buy_price: "1", + current_price: "1", + price_change_1h: null, + price_change_24h: null, + price_change_7d: null, + price_change_30d: null, + price_change_1y: null, + value: "100", + cost_basis: "100", + profit_loss: "0", + profit_loss_percent: 0, + ...overrides, + }; +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..e825ec0 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +// Separate from vite.config.ts (the app config) since the unit tests here +// are plain-function tests over lib/ logic — no dev server, no proxy, no +// React plugin needed, so pulling in the app's own config would just be +// dead weight. Still needs jsdom, not plain "node": lib/i18n.ts reads +// localStorage at module load time (readInitialLanguage), which doesn't +// exist as a Node global. +export default defineConfig({ + resolve: { + // Mirrors tsconfig.app.json's "@/*" path mapping — Vite/Vitest don't + // read tsconfig paths on their own, this is TypeScript-only otherwise. + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "jsdom", + }, +});