mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
test: юнит-тесты на форматирование и разбивки крипто-портфеля + версия 1.1.6
Vitest для чистой логики: lib/format.ts (заодно поймал баг в trimTrailingZeros на "-0.000") и расчёт разбивки по риску/монете/сети на вкладке Crypto — вынес их из компонентов в отдельные тестируемые функции. Тесты подключены в CI. Версия приложения обновлена до 1.1.6.
This commit is contained in:
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
828
frontend/package-lock.json
generated
828
frontend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
40
frontend/src/components/crypto/CryptoAllocationBody.test.ts
Normal file
40
frontend/src/components/crypto/CryptoAllocationBody.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) }))
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<RiskLevel, string> = {
|
||||
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 <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
|
||||
155
frontend/src/lib/format.test.ts
Normal file
155
frontend/src/lib/format.test.ts
Normal file
@@ -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("активов");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
33
frontend/src/test/cryptoFixtures.ts
Normal file
33
frontend/src/test/cryptoFixtures.ts
Normal file
@@ -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> = {}): 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,
|
||||
};
|
||||
}
|
||||
21
frontend/vitest.config.ts
Normal file
21
frontend/vitest.config.ts
Normal file
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user