diff --git a/client/eslint.config.js b/client/eslint.config.js
index a1a0d89..326520c 100644
--- a/client/eslint.config.js
+++ b/client/eslint.config.js
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(['dist', 'coverage', 'node_modules', 'build']),
{
files: ['**/*.{ts,tsx}'],
extends: [
diff --git a/client/package-lock.json b/client/package-lock.json
index 5b07b1c..b4e1ffb 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -21,19 +21,94 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
+ "@vitest/coverage-v8": "^4.1.2",
+ "@vitest/ui": "^4.1.2",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "jsdom": "^29.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
- "vite": "^7.2.4"
+ "vite": "^7.2.4",
+ "vitest": "^4.1.2"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz",
+ "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.1.1",
+ "@csstools/css-color-parser": "^4.0.2",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.7"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.2.7",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
+ "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
+ "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.7"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.7",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
+ "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -215,12 +290,12 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
- "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.6"
+ "@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -303,9 +378,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
- "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -315,6 +390,169 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "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.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
+ "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.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
+ "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
+ "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.0.2",
+ "@csstools/css-calc": "^3.1.1"
+ },
+ "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.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz",
+ "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==",
+ "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/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -1066,6 +1304,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
+ "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
+ "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/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
@@ -1406,6 +1662,13 @@
}
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1773,6 +2036,111 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1818,6 +2186,24 @@
"@babel/types": "^7.28.2"
}
},
+ "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/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.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2172,6 +2558,172 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz",
+ "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.2",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.2",
+ "vitest": "4.1.2"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
+ "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.2",
+ "@vitest/utils": "4.1.2",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
+ "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.2",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "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/pretty-format": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
+ "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
+ "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.2",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
+ "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.2",
+ "@vitest/utils": "4.1.2",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
+ "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/ui": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.2.tgz",
+ "integrity": "sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.2",
+ "fflate": "^0.8.2",
+ "flatted": "^3.4.2",
+ "pathe": "^2.0.3",
+ "sirv": "^3.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "4.1.2"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
+ "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.2",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2212,6 +2764,17 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2235,6 +2798,45 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "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/ast-v8-to-istanbul": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
+ "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2284,6 +2886,16 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2372,6 +2984,16 @@
],
"license": "CC-BY-4.0"
},
+ "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/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2497,12 +3119,47 @@
"node": ">= 8"
}
},
+ "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/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "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/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2520,6 +3177,13 @@
}
}
},
+ "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/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2536,6 +3200,24 @@
"node": ">=0.4.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -2567,6 +3249,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -2594,6 +3289,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -2859,6 +3561,16 @@
"node": ">=4.0"
}
},
+ "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/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2869,6 +3581,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2908,6 +3630,13 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -2959,9 +3688,9 @@
}
},
"node_modules/flatted": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
- "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -3191,6 +3920,26 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "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/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3227,6 +3976,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -3271,6 +4030,13 @@
"node": ">=0.10.0"
}
},
+ "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/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3278,6 +4044,45 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3297,6 +4102,57 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "29.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
+ "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.0.1",
+ "@asamuzakjp/dom-selector": "^7.0.3",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.1",
+ "@exodus/bytes": "^1.15.0",
+ "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.2.7",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.24.5",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.2.7",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
+ "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3424,6 +4280,68 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
+ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3433,6 +4351,13 @@
"node": ">= 0.4"
}
},
+ "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/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -3454,6 +4379,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -3467,6 +4402,16 @@
"node": "*"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3515,6 +4460,17 @@
"node": ">=0.10.0"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3595,6 +4551,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -3630,6 +4599,13 @@
"node": ">=8"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3688,6 +4664,44 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -3831,6 +4845,30 @@
"react-dom": ">=16.6.0"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -3905,6 +4943,19 @@
"fsevents": "~2.3.2"
}
},
+ "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",
@@ -3950,6 +5001,28 @@
"node": ">=8"
}
},
+ "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/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -3969,6 +5042,33 @@
"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.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4013,6 +5113,30 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "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/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
+ "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4030,6 +5154,72 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.0.27",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
+ "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.27"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.27",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
+ "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "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/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -4094,6 +5284,16 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
+ "node_modules/undici": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
+ "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -4217,6 +5417,136 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
+ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.2",
+ "@vitest/mocker": "4.1.2",
+ "@vitest/pretty-format": "4.1.2",
+ "@vitest/runner": "4.1.2",
+ "@vitest/snapshot": "4.1.2",
+ "@vitest/spy": "4.1.2",
+ "@vitest/utils": "4.1.2",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.2",
+ "@vitest/browser-preview": "4.1.2",
+ "@vitest/browser-webdriverio": "4.1.2",
+ "@vitest/ui": "4.1.2",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "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": "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/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4233,6 +5563,23 @@
"node": ">= 8"
}
},
+ "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/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4243,6 +5590,23 @@
"node": ">=0.10.0"
}
},
+ "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"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/client/package.json b/client/package.json
index 6affd8c..3d793c0 100644
--- a/client/package.json
+++ b/client/package.json
@@ -7,7 +7,12 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest --run",
+ "test:watch": "vitest --watch",
+ "test:run": "vitest --run",
+ "test:cov": "vitest --coverage",
+ "test:ui": "vitest --ui"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -23,16 +28,23 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
+ "@vitest/coverage-v8": "^4.1.2",
+ "@vitest/ui": "^4.1.2",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "jsdom": "^29.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
- "vite": "^7.2.4"
+ "vite": "^7.2.4",
+ "vitest": "^4.1.2"
}
}
diff --git a/client/test/App.spec.tsx b/client/test/App.spec.tsx
new file mode 100644
index 0000000..9949cf4
--- /dev/null
+++ b/client/test/App.spec.tsx
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import App from '../src/App'
+
+// Мок для страниц и компонентов
+vi.mock('../src/pages/LoginPage', () => ({
+ default: () =>
LoginPage
,
+}))
+
+vi.mock('../src/pages/SubscriptionsPage', () => ({
+ default: () => SubscriptionsPage
,
+}))
+
+vi.mock('../src/pages/SettingsPage', () => ({
+ default: () => SettingsPage
,
+}))
+
+vi.mock('../src/pages/DomainsPage', () => ({
+ default: () => DomainsPage
,
+}))
+
+vi.mock('../src/pages/TunnelsPage', () => ({
+ default: () => TunnelsPage
,
+}))
+
+vi.mock('../src/pages/NotFoundPage', () => ({
+ default: () => NotFoundPage
,
+}))
+
+vi.mock('../src/components/Layout', () => ({
+ default: () => Layout
,
+}))
+
+// Мок для AuthContext
+vi.mock('../src/auth/AuthContext', () => ({
+ AuthProvider: ({ children }: { children: React.ReactNode }) => children,
+ useAuth: () => ({
+ isAuthenticated: false,
+ login: vi.fn(),
+ logout: vi.fn(),
+ }),
+}))
+
+// Мок для ThemeContext
+vi.mock('../src/ThemeContext', () => ({
+ ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
+ useThemeContext: () => ({
+ mode: 'light' as const,
+ toggleColorMode: vi.fn(),
+ }),
+}))
+
+// Мок для AxiosInterceptor
+vi.mock('../src/auth/AxiosInterceptor', () => ({
+ AxiosInterceptor: () => null,
+}))
+
+// Мок для RequireAuth - просто рендерит children
+vi.mock('../src/auth/RequireAuth', () => ({
+ default: ({ children }: { children: React.ReactNode }) => children,
+}))
+
+// Мок для PublicRoute - просто рендерит children
+vi.mock('../src/auth/PublicRoute', () => ({
+ default: ({ children }: { children: React.ReactNode }) => children,
+}))
+
+describe('App', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('должен рендериться без ошибок', () => {
+ expect(() => {
+ render()
+ }).not.toThrow()
+ })
+
+ it('должен содержать Layout компонент', () => {
+ render()
+ expect(screen.getByTestId('layout')).toBeInTheDocument()
+ })
+})
diff --git a/client/test/ThemeContext.spec.tsx b/client/test/ThemeContext.spec.tsx
new file mode 100644
index 0000000..f1953a4
--- /dev/null
+++ b/client/test/ThemeContext.spec.tsx
@@ -0,0 +1,181 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { ThemeProvider, useThemeContext } from '@/ThemeContext'
+import { ReactNode } from 'react'
+
+// Мокаем localStorage
+const localStorageMock = (() => {
+ let store: Record = {}
+ return {
+ getItem: vi.fn((key: string) => store[key] || null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key]
+ }),
+ clear: vi.fn(() => {
+ store = {}
+ }),
+ }
+})()
+
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+})
+
+// Мокаем useMediaQuery
+const useMediaQueryMock = vi.fn()
+vi.mock('@mui/material', async () => {
+ const actual = await vi.importActual('@mui/material')
+ return {
+ ...(actual as object),
+ useMediaQuery: () => useMediaQueryMock(),
+ }
+})
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+)
+
+describe('ThemeContext', () => {
+ beforeEach(() => {
+ localStorageMock.clear()
+ vi.clearAllMocks()
+ useMediaQueryMock.mockReturnValue(false) // light mode by default
+ })
+
+ describe('useThemeContext', () => {
+ it('должен предоставлять context', () => {
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+ expect(result.current).toBeDefined()
+ })
+
+ it('должен предоставлять mode и toggleColorMode', () => {
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+ expect(result.current.mode).toBeDefined()
+ expect(result.current.toggleColorMode).toBeDefined()
+ expect(typeof result.current.toggleColorMode).toBe('function')
+ })
+ })
+
+ describe('initial state', () => {
+ it('должен инициализироваться с mode из localStorage', () => {
+ localStorageMock.getItem.mockReturnValue('dark')
+
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+
+ expect(result.current.mode).toBe('dark')
+ })
+
+ it('должен инициализироваться с "system" если mode отсутствует в localStorage', () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+
+ expect(result.current.mode).toBe('system')
+ })
+ })
+
+ describe('toggleColorMode', () => {
+ it('должен переключать light → dark → system → light', () => {
+ localStorageMock.getItem.mockReturnValue('light')
+
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+
+ expect(result.current.mode).toBe('light')
+
+ act(() => {
+ result.current.toggleColorMode()
+ })
+ expect(result.current.mode).toBe('dark')
+
+ act(() => {
+ result.current.toggleColorMode()
+ })
+ expect(result.current.mode).toBe('system')
+
+ act(() => {
+ result.current.toggleColorMode()
+ })
+ expect(result.current.mode).toBe('light')
+ })
+
+ it('должен сохранять mode в localStorage при переключении', () => {
+ localStorageMock.getItem.mockReturnValue('light')
+
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+
+ act(() => {
+ result.current.toggleColorMode()
+ })
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('themeMode', 'dark')
+ })
+ })
+
+ describe('system mode', () => {
+ it('должен использовать dark когда prefers-color-scheme: dark', () => {
+ localStorageMock.getItem.mockReturnValue('system')
+ useMediaQueryMock.mockReturnValue(true) // prefers dark
+
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+
+ // mode должен быть 'system', но тема должна быть dark
+ expect(result.current.mode).toBe('system')
+ })
+
+ it('должен использовать light когда prefers-color-scheme: light', () => {
+ localStorageMock.getItem.mockReturnValue('system')
+ useMediaQueryMock.mockReturnValue(false) // prefers light
+
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+
+ expect(result.current.mode).toBe('system')
+ })
+ })
+
+ describe('localStorage persistence', () => {
+ it('должен сохранять mode в localStorage при инициализации', () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ renderHook(() => useThemeContext(), { wrapper })
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('themeMode', 'system')
+ })
+
+ it('должен читать mode из localStorage', () => {
+ localStorageMock.getItem.mockReturnValue('dark')
+
+ renderHook(() => useThemeContext(), { wrapper })
+
+ expect(localStorageMock.getItem).toHaveBeenCalledWith('themeMode')
+ })
+ })
+
+ describe('mode values', () => {
+ it('должен поддерживать light mode', () => {
+ localStorageMock.getItem.mockReturnValue('light')
+
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+
+ expect(result.current.mode).toBe('light')
+ })
+
+ it('должен поддерживать dark mode', () => {
+ localStorageMock.getItem.mockReturnValue('dark')
+
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+
+ expect(result.current.mode).toBe('dark')
+ })
+
+ it('должен поддерживать system mode', () => {
+ localStorageMock.getItem.mockReturnValue('system')
+
+ const { result } = renderHook(() => useThemeContext(), { wrapper })
+
+ expect(result.current.mode).toBe('system')
+ })
+ })
+})
diff --git a/client/test/__mocks__/@mui/icons-material.tsx b/client/test/__mocks__/@mui/icons-material.tsx
new file mode 100644
index 0000000..77c0b90
--- /dev/null
+++ b/client/test/__mocks__/@mui/icons-material.tsx
@@ -0,0 +1,298 @@
+// Mock для всех MUI иконок
+// Этот файл автоматически используется vitest для мока @mui/icons-material
+
+import { forwardRef } from 'react'
+
+// Создаем универсальный мок для любой иконки
+const IconMock = forwardRef((props, ref) => {
+ return (
+
+ )
+})
+
+IconMock.displayName = 'IconMock'
+
+// Экспортируем все возможные иконки как один и тот же мок
+export const GitHub = IconMock
+export const YouTube = IconMock
+export const Telegram = IconMock
+export const Brightness7 = IconMock
+export const Brightness4 = IconMock
+export const BrightnessAuto = IconMock
+export const Logout = IconMock
+export const HelpOutline = IconMock
+export const Menu = IconMock
+export const People = IconMock
+export const Settings = IconMock
+export const Dns = IconMock
+export const SwapHoriz = IconMock
+export const Delete = IconMock
+export const Add = IconMock
+export const Terminal = IconMock
+export const CheckCircle = IconMock
+export const Error = IconMock
+export const LinkIcon = IconMock
+export const OpenInNew = IconMock
+export const ContentCopy = IconMock
+export const Router = IconMock
+export const Edit = IconMock
+export const MoreVert = IconMock
+export const Remove = IconMock
+export const Refresh = IconMock
+export const Search = IconMock
+export const FilterList = IconMock
+export const RefreshTwoTone = IconMock
+export const Warning = IconMock
+export const Info = IconMock
+export const Close = IconMock
+export const Check = IconMock
+export const ArrowDownward = IconMock
+export const ArrowUpward = IconMock
+export const MoreHoriz = IconMock
+export const ContentPaste = IconMock
+export const QrCode = IconMock
+export const Usb = IconMock
+export const VpnKey = IconMock
+export const Security = IconMock
+export const Speed = IconMock
+export const Timeline = IconMock
+export const Assessment = IconMock
+export const SettingsApplications = IconMock
+export const CloudDownload = IconMock
+export const CloudUpload = IconMock
+export const Folder = IconMock
+export const FileCopy = IconMock
+export const Save = IconMock
+export const Print = IconMock
+export const DeleteOutline = IconMock
+export const Restore = IconMock
+export const History = IconMock
+export const Schedule = IconMock
+export const AccessTime = IconMock
+export const Today = IconMock
+export const Event = IconMock
+export const Notifications = IconMock
+export const AccountCircle = IconMock
+export const Person = IconMock
+export const Group = IconMock
+export const Public = IconMock
+export const Language = IconMock
+export const Translate = IconMock
+export const Star = IconMock
+export const Favorite = IconMock
+export const Home = IconMock
+export const LocationOn = IconMock
+export const Place = IconMock
+export const Email = IconMock
+export const Phone = IconMock
+export const Chat = IconMock
+export const Message = IconMock
+export const Forum = IconMock
+export const Share = IconMock
+export const Send = IconMock
+export const Inbox = IconMock
+export const Drafts = IconMock
+export const Mail = IconMock
+export const Markunread = IconMock
+export const Lock = IconMock
+export const LockOpen = IconMock
+export const Unlock = IconMock
+export const Visibility = IconMock
+export const VisibilityOff = IconMock
+export const Eye = IconMock
+export const EyeOff = IconMock
+export const ToggleOn = IconMock
+export const ToggleOff = IconMock
+export const RadioButtonChecked = IconMock
+export const RadioButtonUnchecked = IconMock
+export const CheckBox = IconMock
+export const CheckBoxOutlineBlank = IconMock
+export const IndeterminateCheckBox = IconMock
+export const PlusOne = IconMock
+export const ThumbUp = IconMock
+export const ThumbDown = IconMock
+export const Whatshot = IconMock
+export const FavoriteBorder = IconMock
+export const StarBorder = IconMock
+export const Bookmark = IconMock
+export const BookmarkBorder = IconMock
+export const Bookmarks = IconMock
+export const TurnedIn = IconMock
+export const TurnedInNot = IconMock
+export const Label = IconMock
+export const LabelImportant = IconMock
+export const Grade = IconMock
+export const Done = IconMock
+export const Clear = IconMock
+export const Block = IconMock
+export const Ban = IconMock
+export const Stop = IconMock
+export const Pause = IconMock
+export const PlayArrow = IconMock
+export const FastForward = IconMock
+export const FastRewind = IconMock
+export const SkipNext = IconMock
+export const SkipPrevious = IconMock
+export const FiberManualRecord = IconMock
+export const Circle = IconMock
+export const Square = IconMock
+export const Rectangle = IconMock
+export const Triangle = IconMock
+export const NavigateNext = IconMock
+export const NavigateBefore = IconMock
+export const ChevronRight = IconMock
+export const ChevronLeft = IconMock
+export const ExpandMore = IconMock
+export const ExpandLess = IconMock
+export const UnfoldMore = IconMock
+export const UnfoldLess = IconMock
+export const ArrowRight = IconMock
+export const ArrowLeft = IconMock
+export const ArrowBack = IconMock
+export const ArrowForward = IconMock
+export const ArrowDropDown = IconMock
+export const ArrowDropUp = IconMock
+export const Expand = IconMock
+export const SubdirectoryArrowRight = IconMock
+export const SubdirectoryArrowLeft = IconMock
+export const FileDownload = IconMock
+export const FileUpload = IconMock
+export const Attachment = IconMock
+export const Link = IconMock
+export const InsertLink = IconMock
+export const Photo = IconMock
+export const Image = IconMock
+export const PictureAsPdf = IconMock
+export const ImageIcon = IconMock
+export const CameraAlt = IconMock
+export const Videocam = IconMock
+export const Movie = IconMock
+export const MusicNote = IconMock
+export const Mic = IconMock
+export const VolumeUp = IconMock
+export const VolumeOff = IconMock
+export const Headset = IconMock
+export const Headphones = IconMock
+export const Speaker = IconMock
+export const Radio = IconMock
+export const Podcasts = IconMock
+export const Tv = IconMock
+export const DesktopWindows = IconMock
+export const Laptop = IconMock
+export const Computer = IconMock
+export const Tablet = IconMock
+export const Smartphone = IconMock
+export const PhoneIphone = IconMock
+export const PhoneAndroid = IconMock
+export const Devices = IconMock
+export const SmartDisplay = IconMock
+export const Monitor = IconMock
+export const ScreenShare = IconMock
+export const StopScreenShare = IconMock
+export const PresentToAll = IconMock
+export const Cast = IconMock
+export const CastConnected = IconMock
+export const CastForEducation = IconMock
+export const Wifi = IconMock
+export const WifiOff = IconMock
+export const NetworkWifi = IconMock
+export const NetworkCell = IconMock
+export const SignalCellular4Bar = IconMock
+export const SignalWifi4Bar = IconMock
+export const Bluetooth = IconMock
+export const BluetoothConnected = IconMock
+export const BluetoothDisabled = IconMock
+export const GpsFixed = IconMock
+export const GpsNotFixed = IconMock
+export const LocationSearching = IconMock
+export const MyLocation = IconMock
+export const Navigation = IconMock
+export const NearMe = IconMock
+export const Directions = IconMock
+export const DirectionsCar = IconMock
+export const DirectionsBus = IconMock
+export const DirectionsTrain = IconMock
+export const DirectionsBike = IconMock
+export const DirectionsWalk = IconMock
+export const DirectionsRun = IconMock
+export const Flight = IconMock
+export const LocalAirport = IconMock
+export const Hotel = IconMock
+export const Restaurant = IconMock
+export const LocalCafe = IconMock
+export const LocalBar = IconMock
+export const LocalPizza = IconMock
+export const BrunchDining = IconMock
+export const DinnerDining = IconMock
+export const LunchDining = IconMock
+export const Nightlife = IconMock
+export const LocalHospital = IconMock
+export const LocalPharmacy = IconMock
+export const ShoppingBag = IconMock
+export const ShoppingCart = IconMock
+export const ShoppingBasket = IconMock
+export const Store = IconMock
+export const Shop = IconMock
+export const Storefront = IconMock
+export const LocalMall = IconMock
+export const AccountBalance = IconMock
+export const Business = IconMock
+export const CorporateFare = IconMock
+export const Work = IconMock
+export const MeetingRoom = IconMock
+export const Gite = IconMock
+export const House = IconMock
+export const Cottage = IconMock
+export const Apartment = IconMock
+export const Villa = IconMock
+export const OtherHouses = IconMock
+export const Foundation = IconMock
+export const Fence = IconMock
+export const Yard = IconMock
+export const Pool = IconMock
+export const HotTub = IconMock
+export const Spa = IconMock
+export const FitnessCenter = IconMock
+export const SportsGymnasium = IconMock
+export const SportsBasketball = IconMock
+export const SportsFootball = IconMock
+export const SportsSoccer = IconMock
+export const SportsTennis = IconMock
+export const SportsVolleyball = IconMock
+export const SportsBaseball = IconMock
+export const SportsCricket = IconMock
+export const SportsGolf = IconMock
+export const SportsHockey = IconMock
+export const SportsMma = IconMock
+export const SportsMotorsports = IconMock
+export const SportsRugby = IconMock
+export const SportsScore = IconMock
+export const SportsHandball = IconMock
+export const SportsKabaddi = IconMock
+export const Rowing = IconMock
+export const Surfing = IconMock
+export const Kitesurfing = IconMock
+export const Snowboarding = IconMock
+export const DownhillSkiing = IconMock
+export const Snowshoeing = IconMock
+export const IceSkating = IconMock
+export const Curling = IconMock
+export const Sailing = IconMock
+export const Kayaking = IconMock
+export const Rafting = IconMock
+export const ScubaDiving = IconMock
+export const Diving = IconMock
+export const Fishing = IconMock
+export const Hiking = IconMock
+export const RunningWithErrors = IconMock
+
+// Экспорт по умолчанию
+export default IconMock
diff --git a/client/test/__mocks__/@mui/icons-material/index.jsx b/client/test/__mocks__/@mui/icons-material/index.jsx
new file mode 100644
index 0000000..f4b4b69
--- /dev/null
+++ b/client/test/__mocks__/@mui/icons-material/index.jsx
@@ -0,0 +1,70 @@
+// Mock для всех MUI иконок
+const createIconMock = (name) => {
+ const IconMock = (props) => {
+ return
+ }
+ IconMock.displayName = name
+ return IconMock
+}
+
+// Экспортируем все иконки динамически
+const icons = [
+ 'GitHub', 'YouTube', 'Telegram', 'Brightness7', 'Brightness4', 'BrightnessAuto',
+ 'Logout', 'HelpOutline', 'Menu', 'People', 'Settings', 'Dns', 'SwapHoriz',
+ 'Delete', 'Add', 'Terminal', 'CheckCircle', 'Error', 'LinkIcon', 'OpenInNew',
+ 'ContentCopy', 'Router', 'Edit', 'MoreVert', 'Remove', 'Refresh', 'Search',
+ 'FilterList', 'RefreshTwoTone', 'Warning', 'Info', 'Close', 'Check',
+ 'ArrowDownward', 'ArrowUpward', 'MoreHoriz', 'ContentPaste', 'QrCode', 'Usb',
+ 'VpnKey', 'Security', 'Speed', 'Timeline', 'Assessment', 'SettingsApplications',
+ 'CloudDownload', 'CloudUpload', 'Folder', 'FileCopy', 'Save', 'Print',
+ 'DeleteOutline', 'Restore', 'History', 'Schedule', 'AccessTime', 'Today',
+ 'Event', 'Notifications', 'AccountCircle', 'Person', 'Group', 'Public',
+ 'Language', 'Translate', 'Star', 'Favorite', 'Home', 'LocationOn', 'Place',
+ 'Email', 'Phone', 'Chat', 'Message', 'Forum', 'Share', 'Send', 'Inbox',
+ 'Drafts', 'Mail', 'Markunread', 'Lock', 'LockOpen', 'Unlock', 'Visibility',
+ 'VisibilityOff', 'Eye', 'EyeOff', 'ToggleOn', 'ToggleOff', 'RadioButtonChecked',
+ 'RadioButtonUnchecked', 'CheckBox', 'CheckBoxOutlineBlank', 'IndeterminateCheckBox',
+ 'PlusOne', 'ThumbUp', 'ThumbDown', 'Whatshot', 'FavoriteBorder', 'StarBorder',
+ 'Bookmark', 'BookmarkBorder', 'Bookmarks', 'TurnedIn', 'TurnedInNot', 'Label',
+ 'LabelImportant', 'Grade', 'Done', 'Clear', 'Block', 'Ban', 'Stop', 'Pause',
+ 'PlayArrow', 'FastForward', 'FastRewind', 'SkipNext', 'SkipPrevious',
+ 'FiberManualRecord', 'Circle', 'Square', 'Rectangle', 'Triangle', 'NavigateNext',
+ 'NavigateBefore', 'ChevronRight', 'ChevronLeft', 'ExpandMore', 'ExpandLess',
+ 'UnfoldMore', 'UnfoldLess', 'ArrowRight', 'ArrowLeft', 'ArrowBack', 'ArrowForward',
+ 'ArrowDropDown', 'ArrowDropUp', 'Expand', 'SubdirectoryArrowRight',
+ 'SubdirectoryArrowLeft', 'FileDownload', 'FileUpload', 'Attachment', 'Link',
+ 'InsertLink', 'Photo', 'Image', 'PictureAsPdf', 'ImageIcon', 'CameraAlt',
+ 'Videocam', 'Movie', 'MusicNote', 'Mic', 'VolumeUp', 'VolumeOff', 'Headset',
+ 'Headphones', 'Speaker', 'Radio', 'Podcasts', 'Tv', 'DesktopWindows', 'Laptop',
+ 'Computer', 'Tablet', 'Smartphone', 'PhoneIphone', 'PhoneAndroid', 'Devices',
+ 'SmartDisplay', 'Monitor', 'ScreenShare', 'StopScreenShare', 'PresentToAll',
+ 'Cast', 'CastConnected', 'CastForEducation', 'Wifi', 'WifiOff', 'NetworkWifi',
+ 'NetworkCell', 'SignalCellular4Bar', 'SignalWifi4Bar', 'Bluetooth',
+ 'BluetoothConnected', 'BluetoothDisabled', 'GpsFixed', 'GpsNotFixed',
+ 'LocationSearching', 'MyLocation', 'Navigation', 'NearMe', 'Directions',
+ 'DirectionsCar', 'DirectionsBus', 'DirectionsTrain', 'DirectionsBike',
+ 'DirectionsWalk', 'DirectionsRun', 'Flight', 'LocalAirport', 'Hotel',
+ 'Restaurant', 'LocalCafe', 'LocalBar', 'LocalPizza', 'BrunchDining',
+ 'DinnerDining', 'LunchDining', 'Nightlife', 'LocalHospital', 'LocalPharmacy',
+ 'ShoppingBag', 'ShoppingCart', 'ShoppingBasket', 'Store', 'Shop', 'Storefront',
+ 'LocalMall', 'AccountBalance', 'Business', 'CorporateFare', 'Work',
+ 'MeetingRoom', 'Gite', 'House', 'Cottage', 'Apartment', 'Villa', 'OtherHouses',
+ 'Foundation', 'Fence', 'Yard', 'Pool', 'HotTub', 'Spa', 'FitnessCenter',
+ 'SportsGymnasium', 'SportsBasketball', 'SportsFootball', 'SportsSoccer',
+ 'SportsTennis', 'SportsVolleyball', 'SportsBaseball', 'SportsCricket',
+ 'SportsGolf', 'SportsHockey', 'SportsMma', 'SportsMotorsports', 'SportsRugby',
+ 'SportsScore', 'SportsHandball', 'SportsKabaddi', 'Rowing', 'Surfing',
+ 'Kitesurfing', 'Snowboarding', 'DownhillSkiing', 'Snowshoeing', 'IceSkating',
+ 'Curling', 'Sailing', 'Kayaking', 'Rafting', 'ScubaDiving', 'Diving', 'Fishing',
+ 'Hiking', 'RunningWithErrors', 'PlayCircleFilled', 'PauseCircleFilled',
+ 'CheckCircle', 'Dns'
+]
+
+// Создаем экспорт для каждой иконки
+const exportsObj = {}
+icons.forEach(name => {
+ exportsObj[name] = createIconMock(name)
+})
+
+module.exports = exportsObj
+module.exports.default = createIconMock('DefaultIcon')
diff --git a/client/test/__mocks__/@mui/icons-material/package.json b/client/test/__mocks__/@mui/icons-material/package.json
new file mode 100644
index 0000000..ccb7f8c
--- /dev/null
+++ b/client/test/__mocks__/@mui/icons-material/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "@mui/icons-material",
+ "main": "index.js",
+ "module": "index.js"
+}
diff --git a/client/test/api.spec.ts b/client/test/api.spec.ts
new file mode 100644
index 0000000..b0386d4
--- /dev/null
+++ b/client/test/api.spec.ts
@@ -0,0 +1,173 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+// Мок для localStorage
+const localStorageMock = (() => {
+ let store: Record = {}
+ return {
+ getItem: vi.fn((key: string) => store[key] || null),
+ setItem: vi.fn((key: string, value: string) => { store[key] = value }),
+ removeItem: vi.fn((key: string) => { delete store[key] }),
+ clear: vi.fn(() => { store = {} }),
+ }
+})()
+
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+})
+
+// Динамический импорт после настройки моков
+let api: typeof import('@/api').default
+
+describe('api', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ // Очищаем модуль перед каждым тестом
+ await vi.resetModules()
+ const module = await import('@/api')
+ api = module.default
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('API instance', () => {
+ it('должен быть создан с baseURL /api', () => {
+ expect(api.defaults.baseURL).toBe('/api')
+ })
+ })
+
+ describe('Request interceptor', () => {
+ it('должен добавлять токен из localStorage в заголовок Authorization', async () => {
+ localStorageMock.getItem.mockReturnValue('test-token')
+
+ const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
+ api.defaults.adapter = mockAdapter
+
+ await api.get('/test')
+
+ const config = mockAdapter.mock.calls[0][0]
+ expect(config.headers.get('Authorization')).toBe('Bearer test-token')
+ })
+
+ it('не должен добавлять токен если он отсутствует', async () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
+ api.defaults.adapter = mockAdapter
+
+ await api.get('/test')
+
+ const config = mockAdapter.mock.calls[0][0]
+ expect(config.headers.get('Authorization')).toBeFalsy()
+ })
+
+ it('должен логировать запрос с существующим токеном', async () => {
+ localStorageMock.getItem.mockReturnValue('test-token')
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
+ api.defaults.adapter = mockAdapter
+
+ await api.get('/test')
+
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Token: EXISTS'), expect.any(String))
+ consoleSpy.mockRestore()
+ })
+
+ it('должен логировать запрос без токена', async () => {
+ localStorageMock.getItem.mockReturnValue(null)
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
+ api.defaults.adapter = mockAdapter
+
+ await api.get('/test')
+
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Token: NULL'), expect.any(String))
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('Response interceptor', () => {
+ it('должен логировать успешный ответ', async () => {
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ const mockAdapter = vi.fn(() =>
+ Promise.resolve({
+ data: { result: 'ok' },
+ status: 200,
+ headers: {},
+ config: { method: 'get', url: '/test' }
+ })
+ )
+ api.defaults.adapter = mockAdapter
+
+ const response = await api.get('/test')
+
+ expect(response.status).toBe(200)
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('200 OK'), expect.any(String))
+ consoleSpy.mockRestore()
+ })
+
+ it('должен логировать ошибку с status кодом', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const mockAdapter = vi.fn(() =>
+ Promise.reject({
+ response: { status: 401, data: { message: 'Unauthorized' } },
+ config: { method: 'get', url: '/test' }
+ })
+ )
+ api.defaults.adapter = mockAdapter
+
+ await expect(api.get('/test')).rejects.toThrow()
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('ERROR 401: Unauthorized'),
+ expect.any(String)
+ )
+ consoleSpy.mockRestore()
+ })
+
+ it('должен логировать сетевую ошибку без status', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const mockAdapter = vi.fn(() =>
+ Promise.reject({
+ message: 'Network Error',
+ config: { method: 'get', url: '/test' }
+ })
+ )
+ api.defaults.adapter = mockAdapter
+
+ await expect(api.get('/test')).rejects.toThrow()
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('ERROR NETWORK: Network Error'),
+ expect.any(String)
+ )
+ consoleSpy.mockRestore()
+ })
+
+ it('должен возвращать сообщение из response.data.message', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const mockAdapter = vi.fn(() =>
+ Promise.reject({
+ response: { status: 400, data: { message: 'Bad Request' } },
+ config: { method: 'post', url: '/create' }
+ })
+ )
+ api.defaults.adapter = mockAdapter
+
+ await expect(api.post('/create')).rejects.toThrow()
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Bad Request'),
+ expect.any(String)
+ )
+ consoleSpy.mockRestore()
+ })
+ })
+})
diff --git a/client/test/auth/AuthContext.spec.tsx b/client/test/auth/AuthContext.spec.tsx
new file mode 100644
index 0000000..73805c9
--- /dev/null
+++ b/client/test/auth/AuthContext.spec.tsx
@@ -0,0 +1,202 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { AuthProvider, useAuth } from '@/auth/AuthContext'
+import { ReactNode } from 'react'
+
+// Мокаем localStorage
+const localStorageMock = (() => {
+ let store: Record = {}
+ return {
+ getItem: vi.fn((key: string) => store[key] || null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key]
+ }),
+ clear: vi.fn(() => {
+ store = {}
+ }),
+ }
+})()
+
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+})
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+)
+
+describe('AuthContext', () => {
+ beforeEach(() => {
+ localStorageMock.clear()
+ vi.clearAllMocks()
+ })
+
+ describe('useAuth', () => {
+ it('должен выбрасывать ошибку при использовании вне AuthProvider', () => {
+ // Отключаем console.error для этого теста
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ expect(() => {
+ renderHook(() => useAuth())
+ }).toThrow('useAuth must be used within an AuthProvider')
+
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('initial state', () => {
+ it('должен инициализироваться с token из localStorage', () => {
+ localStorageMock.getItem.mockReturnValue('test-token-123')
+
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ expect(result.current.token).toBe('test-token-123')
+ expect(result.current.isAuthenticated).toBe(true)
+ expect(localStorageMock.getItem).toHaveBeenCalledWith('token')
+ })
+
+ it('должен инициализироваться с null если token отсутствует в localStorage', () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ expect(result.current.token).toBe(null)
+ expect(result.current.isAuthenticated).toBe(false)
+ })
+ })
+
+ describe('login', () => {
+ it('должен сохранять токен в localStorage и state', () => {
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ act(() => {
+ result.current.login('new-token-456')
+ })
+
+ expect(result.current.token).toBe('new-token-456')
+ expect(result.current.isAuthenticated).toBe(true)
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('token', 'new-token-456')
+ })
+
+ it('должен обновлять isAuthenticated после login', () => {
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ expect(result.current.isAuthenticated).toBe(false)
+
+ act(() => {
+ result.current.login('another-token')
+ })
+
+ expect(result.current.isAuthenticated).toBe(true)
+ })
+ })
+
+ describe('logout', () => {
+ it('должен удалять токен из localStorage и state', () => {
+ localStorageMock.getItem.mockReturnValue('existing-token')
+
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ expect(result.current.token).toBe('existing-token')
+
+ act(() => {
+ result.current.logout()
+ })
+
+ expect(result.current.token).toBe(null)
+ expect(result.current.isAuthenticated).toBe(false)
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
+ })
+
+ it('должен корректно работать logout когда token уже null', () => {
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ act(() => {
+ result.current.logout()
+ })
+
+ expect(result.current.token).toBe(null)
+ expect(result.current.isAuthenticated).toBe(false)
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
+ })
+ })
+
+ describe('isAuthenticated', () => {
+ it('должен возвращать true когда token существует', () => {
+ localStorageMock.getItem.mockReturnValue('valid-token')
+
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ expect(result.current.isAuthenticated).toBe(true)
+ })
+
+ it('должен возвращать false когда token null', () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ expect(result.current.isAuthenticated).toBe(false)
+ })
+
+ it('должен возвращать false когда token пустая строка', () => {
+ localStorageMock.getItem.mockReturnValue('')
+
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ expect(result.current.isAuthenticated).toBe(false)
+ })
+ })
+
+ describe('context methods', () => {
+ it('должен предоставлять метод login', () => {
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ expect(result.current.login).toBeDefined()
+ expect(typeof result.current.login).toBe('function')
+ })
+
+ it('должен предоставлять метод logout', () => {
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ expect(result.current.logout).toBeDefined()
+ expect(typeof result.current.logout).toBe('function')
+ })
+ })
+
+ describe('multiple login/logout cycles', () => {
+ it('должен корректно обрабатывать несколько циклов login/logout', () => {
+ const { result } = renderHook(() => useAuth(), { wrapper })
+
+ // Первый цикл
+ act(() => {
+ result.current.login('token-1')
+ })
+ expect(result.current.token).toBe('token-1')
+
+ act(() => {
+ result.current.logout()
+ })
+ expect(result.current.token).toBe(null)
+
+ // Второй цикл
+ act(() => {
+ result.current.login('token-2')
+ })
+ expect(result.current.token).toBe('token-2')
+
+ act(() => {
+ result.current.logout()
+ })
+ expect(result.current.token).toBe(null)
+
+ // Третий цикл
+ act(() => {
+ result.current.login('token-3')
+ })
+ expect(result.current.token).toBe('token-3')
+ })
+ })
+})
diff --git a/client/test/auth/AxiosInterceptor.spec.tsx b/client/test/auth/AxiosInterceptor.spec.tsx
new file mode 100644
index 0000000..152734b
--- /dev/null
+++ b/client/test/auth/AxiosInterceptor.spec.tsx
@@ -0,0 +1,174 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, waitFor } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import { AxiosInterceptor } from '../../src/auth/AxiosInterceptor'
+import { AuthProvider } from '../../src/auth/AuthContext'
+import { ThemeProvider } from '../../src/ThemeContext'
+import { Logger } from '../../src/utils/logger'
+
+const mockNavigate = vi.fn()
+const mockLogout = vi.fn()
+
+// Мок api с интерсепторами - используем vi.hoisted для подъёма
+const mocks = vi.hoisted(() => ({
+ responseUse: vi.fn(),
+ responseEject: vi.fn(),
+}))
+
+vi.mock('../../src/api', () => ({
+ default: {
+ interceptors: {
+ request: {
+ use: vi.fn(),
+ eject: vi.fn(),
+ },
+ response: {
+ use: mocks.responseUse,
+ eject: mocks.responseEject,
+ },
+ },
+ },
+}))
+
+// Мок react-router-dom
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom')
+ return {
+ ...(actual as object),
+ useNavigate: () => mockNavigate,
+ useLocation: () => ({ pathname: '/subscriptions' }),
+ }
+})
+
+// Мок AuthContext
+vi.mock('../../src/auth/AuthContext', async () => {
+ const actual = await vi.importActual('../../src/auth/AuthContext')
+ return {
+ ...(actual as object),
+ useAuth: () => ({ logout: mockLogout }),
+ }
+})
+
+vi.mock('../../src/utils/logger', () => ({
+ Logger: {
+ warn: vi.fn(),
+ debug: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ },
+}))
+
+describe('AxiosInterceptor', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockNavigate.mockClear()
+ mockLogout.mockClear()
+ vi.mocked(Logger.warn).mockClear()
+ vi.mocked(Logger.debug).mockClear()
+ mocks.responseUse.mockClear()
+ mocks.responseEject.mockClear()
+ })
+
+ it('должен рендериться без ошибок и возвращать null', () => {
+ const { container } = render(
+
+
+
+
+
+
+
+ )
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('должен регистрировать interceptor при монтировании', async () => {
+ render(
+
+
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mocks.responseUse).toHaveBeenCalled()
+ })
+
+ // Проверяем что был зарегистрирован обработчик
+ const interceptorCall = vi.mocked(mocks.responseUse).mock.calls[0]
+ expect(interceptorCall).toBeDefined()
+ expect(typeof interceptorCall?.[0]).toBe('function') // success handler
+ expect(typeof interceptorCall?.[1]).toBe('function') // error handler
+ })
+
+ it('должен пропускать успешные ответы', () => {
+ const successHandler = (response: unknown) => response
+
+ const response = { data: { test: 'value' }, status: 200 }
+ const result = successHandler(response)
+
+ expect(result).toEqual(response)
+ })
+
+ it('должен отклонять ошибки не 401', async () => {
+ const errorHandler = (error: unknown) => Promise.reject(error)
+
+ const errorResponse = { response: { status: 500 } }
+
+ await expect(errorHandler(errorResponse)).rejects.toEqual(errorResponse)
+ })
+
+ it('должен удалять interceptor при размонтировании', async () => {
+ const { unmount } = render(
+
+
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mocks.responseUse).toHaveBeenCalled()
+ })
+
+ unmount()
+
+ expect(mocks.responseEject).toHaveBeenCalled()
+ })
+
+ it('должен обрабатывать 401 ошибки', async () => {
+ render(
+
+
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mocks.responseUse).toHaveBeenCalled()
+ })
+
+ const interceptorCall = vi.mocked(mocks.responseUse).mock.calls[0]
+ const errorHandler = interceptorCall?.[1]
+
+ if (errorHandler) {
+ try {
+ await errorHandler({ response: { status: 401 } })
+ } catch {
+ // Ожидаем что ошибка будет проброшена дальше
+ }
+ }
+
+ // Проверяем что logout и navigate были вызваны
+ expect(mockLogout).toHaveBeenCalled()
+ expect(mockNavigate).toHaveBeenCalledWith('/login')
+ })
+})
diff --git a/client/test/auth/PublicRoute.spec.tsx b/client/test/auth/PublicRoute.spec.tsx
new file mode 100644
index 0000000..3ca8806
--- /dev/null
+++ b/client/test/auth/PublicRoute.spec.tsx
@@ -0,0 +1,135 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter, Routes, Route } from 'react-router-dom'
+import PublicRoute from '@/auth/PublicRoute'
+import { AuthProvider } from '@/auth/AuthContext'
+
+// Мокаем localStorage
+const localStorageMock = (() => {
+ let store: Record = {}
+ return {
+ getItem: vi.fn((key: string) => store[key] || null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key]
+ }),
+ clear: vi.fn(() => {
+ store = {}
+ }),
+ }
+})()
+
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+})
+
+describe('PublicRoute', () => {
+ beforeEach(() => {
+ localStorageMock.clear()
+ vi.clearAllMocks()
+ })
+
+ it('должен рендерить Outlet когда пользователь не авторизован', () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ render(
+
+
+
+ }>
+ Login Page} />
+
+
+
+
+ )
+
+ expect(screen.getByTestId('login-page')).toBeInTheDocument()
+ expect(screen.getByText('Login Page')).toBeInTheDocument()
+ })
+
+ it('должен перенаправлять на / когда пользователь авторизован', () => {
+ localStorageMock.getItem.mockReturnValue('valid-token')
+
+ render(
+
+
+
+ }>
+ Login Page} />
+
+ Home Page} />
+
+
+
+ )
+
+ // Должен показать главную страницу вместо login
+ expect(screen.getByTestId('home-page')).toBeInTheDocument()
+ expect(screen.getByText('Home Page')).toBeInTheDocument()
+ })
+
+ it('должен работать с несколькими публичными роутами', () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ render(
+
+
+
+ }>
+ Login Page} />
+ Register Page} />
+
+
+
+
+ )
+
+ expect(screen.getByTestId('register-page')).toBeInTheDocument()
+ expect(screen.getByText('Register Page')).toBeInTheDocument()
+ })
+
+ it('должен перенаправлять авторизованного пользователя с любого публичного роута', () => {
+ localStorageMock.getItem.mockReturnValue('valid-token')
+
+ render(
+
+
+
+ }>
+ Login Page} />
+ Register Page} />
+
+ Home Page} />
+
+
+
+ )
+
+ // Должен показать главную страницу
+ expect(screen.getByTestId('home-page')).toBeInTheDocument()
+ expect(screen.queryByTestId('register-page')).not.toBeInTheDocument()
+ })
+
+ it('должен использовать replace при перенаправлении', () => {
+ localStorageMock.getItem.mockReturnValue('valid-token')
+
+ const { container } = render(
+
+
+
+ }>
+ Login} />
+
+ Home} />
+
+
+
+ )
+
+ // Проверяем что рендерится Home
+ expect(container.textContent).toContain('Home')
+ })
+})
diff --git a/client/test/auth/RequireAuth.spec.tsx b/client/test/auth/RequireAuth.spec.tsx
new file mode 100644
index 0000000..a006173
--- /dev/null
+++ b/client/test/auth/RequireAuth.spec.tsx
@@ -0,0 +1,186 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter, Routes, Route } from 'react-router-dom'
+import RequireAuth from '@/auth/RequireAuth'
+import { AuthProvider } from '@/auth/AuthContext'
+
+// Мокаем localStorage
+const localStorageMock = (() => {
+ let store: Record = {}
+ return {
+ getItem: vi.fn((key: string) => store[key] || null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key]
+ }),
+ clear: vi.fn(() => {
+ store = {}
+ }),
+ }
+})()
+
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+})
+
+describe('RequireAuth', () => {
+ beforeEach(() => {
+ localStorageMock.clear()
+ vi.clearAllMocks()
+ })
+
+ it('должен рендерить children когда пользователь авторизован', () => {
+ localStorageMock.getItem.mockReturnValue('valid-token')
+
+ render(
+
+
+
+
+ Protected Content
+
+ }
+ />
+
+
+
+ )
+
+ expect(screen.getByTestId('protected-content')).toBeInTheDocument()
+ expect(screen.getByText('Protected Content')).toBeInTheDocument()
+ })
+
+ it('должен перенаправлять на /login когда пользователь не авторизован', () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ render(
+
+
+
+
+ Protected Content
+
+ }
+ />
+ Login Page} />
+
+
+
+ )
+
+ expect(screen.getByTestId('login-page')).toBeInTheDocument()
+ expect(screen.getByText('Login Page')).toBeInTheDocument()
+ })
+
+ it('должен сохранять state.from с текущим location при перенаправлении на login', () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ render(
+
+
+
+
+ Settings
+
+ }
+ />
+ Login Page} />
+
+
+
+ )
+
+ // Должен показать login страницу
+ expect(screen.getByTestId('login-page')).toBeInTheDocument()
+ })
+
+ it('должен использовать replace: true при перенаправлении', () => {
+ // Этот тест проверяет поведение Navigate компонента
+ // В реальном сценарии replace предотвращает добавление записи в историю
+ localStorageMock.getItem.mockReturnValue(null)
+
+ const { container } = render(
+
+
+
+
+ Protected
+
+ }
+ />
+ Login} />
+
+
+
+ )
+
+ // Проверяем что рендерится login
+ expect(container.textContent).toContain('Login')
+ })
+
+ it('должен работать с вложенными роутами', () => {
+ localStorageMock.getItem.mockReturnValue('valid-token')
+
+ render(
+
+
+
+
+
+ Profile} />
+
+
+ }
+ />
+
+
+
+ )
+
+ expect(screen.getByTestId('profile')).toBeInTheDocument()
+ })
+
+ it('должен блокировать доступ к защищённому маршруту без авторизации', () => {
+ localStorageMock.getItem.mockReturnValue(null)
+
+ render(
+
+
+
+
+ Admin Panel
+
+ }
+ />
+ Login Page} />
+
+
+
+ )
+
+ // Контент админки не должен быть доступен
+ expect(screen.queryByTestId('admin-content')).not.toBeInTheDocument()
+ // Должна показываться страница логина
+ expect(screen.getByTestId('login-page')).toBeInTheDocument()
+ })
+})
diff --git a/client/test/components/Footer.spec.tsx b/client/test/components/Footer.spec.tsx
new file mode 100644
index 0000000..de8d9de
--- /dev/null
+++ b/client/test/components/Footer.spec.tsx
@@ -0,0 +1,78 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import Footer from '../../src/components/Footer'
+
+describe('Footer', () => {
+ const renderFooter = (props: Partial> = {}) => {
+ return render(
+
+
+
+ )
+ }
+
+ it('должен рендериться с логотипом', () => {
+ renderFooter()
+ const logo = screen.getByAltText('Logo')
+ expect(logo).toBeInTheDocument()
+ expect(logo).toHaveAttribute('src', '/img/logo.png')
+ })
+
+ it('должен отображать ссылку на документацию', () => {
+ renderFooter()
+ const docLink = screen.getByText('Документация')
+ expect(docLink).toBeInTheDocument()
+ expect(docLink).toHaveAttribute('href', 'https://3dp-manager.com/docs/intro')
+ expect(docLink).toHaveAttribute('target', '_blank')
+ expect(docLink).toHaveAttribute('rel', 'noopener')
+ })
+
+ it('должен отображать иконку GitHub', () => {
+ renderFooter()
+ const githubButton = screen.getByLabelText('GitHub')
+ expect(githubButton).toBeInTheDocument()
+ expect(githubButton.closest('a')).toHaveAttribute(
+ 'href',
+ 'https://github.com/denpiligrim/3dp-manager'
+ )
+ })
+
+ it('должен отображать иконку YouTube', () => {
+ renderFooter()
+ const youtubeButton = screen.getByLabelText('YouTube')
+ expect(youtubeButton).toBeInTheDocument()
+ expect(youtubeButton.closest('a')).toHaveAttribute(
+ 'href',
+ 'https://youtube.com/@denpiligrim'
+ )
+ })
+
+ it('должен отображать иконку Telegram', () => {
+ renderFooter()
+ const telegramButton = screen.getByLabelText('Telegram')
+ expect(telegramButton).toBeInTheDocument()
+ expect(telegramButton.closest('a')).toHaveAttribute(
+ 'href',
+ 'https://t.me/denpiligrim_web'
+ )
+ })
+
+ it('должен принимать prop isMobile', () => {
+ renderFooter({ isMobile: true })
+ expect(screen.getByAltText('Logo')).toBeInTheDocument()
+ })
+
+ it('должен иметь правильный семантический тег footer', () => {
+ renderFooter()
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument()
+ })
+
+ it('должен иметь правильный layout с Grid', () => {
+ renderFooter()
+ // Проверяем, что все три колонки присутствуют
+ expect(screen.getByText('Документация')).toBeInTheDocument()
+ expect(screen.getByLabelText('GitHub')).toBeInTheDocument()
+ expect(screen.getByAltText('Logo')).toBeInTheDocument()
+ })
+})
diff --git a/client/test/components/Header.spec.tsx b/client/test/components/Header.spec.tsx
new file mode 100644
index 0000000..b1047c5
--- /dev/null
+++ b/client/test/components/Header.spec.tsx
@@ -0,0 +1,213 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import Header from '../../src/components/Header'
+import { useThemeContext } from '../../src/ThemeContext'
+import { useAuth } from '../../src/auth/AuthContext'
+
+vi.mock('../../src/ThemeContext', () => ({
+ useThemeContext: vi.fn(),
+}))
+
+vi.mock('../../src/auth/AuthContext', () => ({
+ useAuth: vi.fn(),
+}))
+
+describe('Header', () => {
+ const mockToggleColorMode = vi.fn()
+ const mockLogout = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useThemeContext).mockReturnValue({
+ mode: 'light',
+ toggleColorMode: mockToggleColorMode,
+ })
+ vi.mocked(useAuth).mockReturnValue({
+ isAuthenticated: true,
+ login: vi.fn(),
+ logout: mockLogout,
+ })
+ })
+
+ const renderHeader = (props: Partial> = {}) => {
+ return render(
+
+
+
+ )
+ }
+
+ it('должен рендериться с логотипом и названием', () => {
+ renderHeader()
+ expect(screen.getByText('3DP-MANAGER')).toBeInTheDocument()
+ const logo = screen.getByAltText('Logo')
+ expect(logo).toBeInTheDocument()
+ expect(logo).toHaveAttribute('src', '/img/logo.png')
+ })
+
+ it('должен отображать иконку справки', () => {
+ renderHeader()
+ const helpButton = screen.getByLabelText('Справка о программе')
+ expect(helpButton).toBeInTheDocument()
+ })
+
+ it('должен открывать диалог справки при клике на иконку справки', async () => {
+ renderHeader()
+ const helpButton = screen.getByLabelText('Справка о программе')
+ fireEvent.click(helpButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Об утилите 3DP-MANAGER')).toBeInTheDocument()
+ })
+ expect(screen.getByText(/Утилита для автогенерации инбаундов/)).toBeInTheDocument()
+ })
+
+ it('должен закрывать диалог справки при клике на кнопку "Понятно"', async () => {
+ renderHeader()
+ const helpButton = screen.getByLabelText('Справка о программе')
+ fireEvent.click(helpButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Об утилите 3DP-MANAGER')).toBeInTheDocument()
+ })
+
+ const closeButton = screen.getByText('Понятно')
+ fireEvent.click(closeButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText('Об утилите 3DP-MANAGER')).not.toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать иконку темы', () => {
+ renderHeader()
+ const themeButton = screen.getByLabelText('Режим: Светлая тема')
+ expect(themeButton).toBeInTheDocument()
+ })
+
+ it('должен вызывать toggleColorMode при клике на иконку темы', () => {
+ renderHeader()
+ const themeButton = screen.getByLabelText('Режим: Светлая тема')
+ fireEvent.click(themeButton)
+ expect(mockToggleColorMode).toHaveBeenCalled()
+ })
+
+ it('должен отображать правильную иконку для light mode', () => {
+ vi.mocked(useThemeContext).mockReturnValue({
+ mode: 'light',
+ toggleColorMode: mockToggleColorMode,
+ })
+ renderHeader()
+ expect(screen.getByLabelText('Режим: Светлая тема')).toBeInTheDocument()
+ })
+
+ it('должен отображать правильную иконку для dark mode', () => {
+ vi.mocked(useThemeContext).mockReturnValue({
+ mode: 'dark',
+ toggleColorMode: mockToggleColorMode,
+ })
+ renderHeader()
+ expect(screen.getByLabelText('Режим: Темная тема')).toBeInTheDocument()
+ })
+
+ it('должен отображать правильную иконку для system mode', () => {
+ vi.mocked(useThemeContext).mockReturnValue({
+ mode: 'system',
+ toggleColorMode: mockToggleColorMode,
+ })
+ renderHeader()
+ expect(screen.getByLabelText('Режим: Системная тема')).toBeInTheDocument()
+ })
+
+ it('должен отображать иконку выхода', () => {
+ renderHeader()
+ const logoutButton = screen.getByLabelText('Выйти из системы')
+ expect(logoutButton).toBeInTheDocument()
+ })
+
+ it('должен открывать диалог подтверждения при клике на выход', async () => {
+ renderHeader()
+ const logoutButton = screen.getByLabelText('Выйти из системы')
+ fireEvent.click(logoutButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Вы действительно хотите выйти?')).toBeInTheDocument()
+ })
+ })
+
+ it('должен закрывать диалог подтверждения при клике на "Отмена"', async () => {
+ renderHeader()
+ const logoutButton = screen.getByLabelText('Выйти из системы')
+ fireEvent.click(logoutButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Вы действительно хотите выйти?')).toBeInTheDocument()
+ })
+
+ const cancelButton = screen.getByText('Отмена')
+ fireEvent.click(cancelButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText('Вы действительно хотите выйти?')).not.toBeInTheDocument()
+ })
+ })
+
+ it('должен вызывать logout при подтверждении выхода', async () => {
+ renderHeader()
+ const logoutButton = screen.getByLabelText('Выйти из системы')
+ fireEvent.click(logoutButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Вы действительно хотите выйти?')).toBeInTheDocument()
+ })
+
+ const confirmButton = screen.getByText('Выйти')
+ fireEvent.click(confirmButton)
+
+ expect(mockLogout).toHaveBeenCalled()
+ })
+
+ it('должен принимать prop isMobile', () => {
+ renderHeader({ isMobile: true })
+ expect(screen.getByText('3DP-MANAGER')).toBeInTheDocument()
+ })
+
+ it('должен отображать кнопку меню в мобильном режиме', () => {
+ const onMenuClick = vi.fn()
+ renderHeader({ isMobile: true, onMenuClick })
+
+ const menuButton = screen.getByTestId('icon-Menu').closest('button')
+ expect(menuButton).toBeInTheDocument()
+
+ if (menuButton) {
+ fireEvent.click(menuButton)
+ expect(onMenuClick).toHaveBeenCalled()
+ }
+ })
+
+ it('должен отображать версию в диалоге справки', async () => {
+ renderHeader()
+ const helpButton = screen.getByLabelText('Справка о программе')
+ fireEvent.click(helpButton)
+
+ await waitFor(() => {
+ expect(screen.getByText(/Разработчик:/)).toBeInTheDocument()
+ })
+ // Версия отображается отдельным текстом с br переносом
+ expect(screen.getByText(/\d+\.\d+\.\d+/)).toBeInTheDocument()
+ })
+
+ it('должен отображать список возможностей в диалоге справки', async () => {
+ renderHeader()
+ const helpButton = screen.getByLabelText('Справка о программе')
+ fireEvent.click(helpButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Автоматическая генерация')).toBeInTheDocument()
+ })
+ expect(screen.getByText('Управление подписками')).toBeInTheDocument()
+ expect(screen.getByText('Белый список доменов')).toBeInTheDocument()
+ expect(screen.getByText('Перенаправление')).toBeInTheDocument()
+ })
+})
diff --git a/client/test/components/Layout.spec.tsx b/client/test/components/Layout.spec.tsx
new file mode 100644
index 0000000..320acc6
--- /dev/null
+++ b/client/test/components/Layout.spec.tsx
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import Layout from '../../src/components/Layout'
+import { useThemeContext } from '../../src/ThemeContext'
+import { useAuth } from '../../src/auth/AuthContext'
+
+vi.mock('../../src/ThemeContext', () => ({
+ useThemeContext: vi.fn(),
+}))
+
+vi.mock('../../src/auth/AuthContext', () => ({
+ useAuth: vi.fn(),
+}))
+
+describe('Layout', () => {
+ const mockToggleColorMode = vi.fn()
+ const mockLogout = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useThemeContext).mockReturnValue({
+ mode: 'light',
+ toggleColorMode: mockToggleColorMode,
+ })
+ vi.mocked(useAuth).mockReturnValue({
+ isAuthenticated: true,
+ login: vi.fn(),
+ logout: mockLogout,
+ })
+ })
+
+ const renderLayout = (initialPath = '/') => {
+ return render(
+
+
+ }>
+ SubscriptionsPage} />
+ DomainsPage} />
+ TunnelsPage} />
+ SettingsPage} />
+
+
+
+ )
+ }
+
+ it('должен рендериться без ошибок', () => {
+ renderLayout()
+ expect(screen.getByTestId('outlet')).toBeInTheDocument()
+ })
+
+ it('должен отображать Header', () => {
+ renderLayout()
+ expect(screen.getByText('3DP-MANAGER')).toBeInTheDocument()
+ })
+
+ it('должен отображать навигационное меню', () => {
+ renderLayout()
+ expect(screen.getByText('Подписки')).toBeInTheDocument()
+ expect(screen.getByText('Домены')).toBeInTheDocument()
+ expect(screen.getByText('Перенаправление')).toBeInTheDocument()
+ expect(screen.getByText('Настройки')).toBeInTheDocument()
+ })
+
+ it('должен выделять активный пункт меню для главной страницы', () => {
+ renderLayout('/')
+ const subscriptionsItem = screen.getByText('Подписки').closest('.Mui-selected')
+ expect(subscriptionsItem).toBeInTheDocument()
+ })
+
+ it('должен выделять активный пункт меню для страницы доменов', () => {
+ renderLayout('/domains')
+ const domainsItem = screen.getByText('Домены').closest('.Mui-selected')
+ expect(domainsItem).toBeInTheDocument()
+ })
+
+ it('должен выделять активный пункт меню для страницы туннелей', () => {
+ renderLayout('/tunnels')
+ const tunnelsItem = screen.getByText('Перенаправление').closest('.Mui-selected')
+ expect(tunnelsItem).toBeInTheDocument()
+ })
+
+ it('должен выделять активный пункт меню для страницы настроек', () => {
+ renderLayout('/settings')
+ const settingsItem = screen.getByText('Настройки').closest('.Mui-selected')
+ expect(settingsItem).toBeInTheDocument()
+ })
+
+ it('должен переходить на главную при клике на "Подписки"', () => {
+ renderLayout('/domains')
+ const subscriptionsLink = screen.getByText('Подписки')
+ fireEvent.click(subscriptionsLink)
+ expect(screen.getByText('SubscriptionsPage')).toBeInTheDocument()
+ })
+
+ it('должен переходить на страницу доменов при клике на "Домены"', () => {
+ renderLayout('/')
+ const domainsLink = screen.getByText('Домены')
+ fireEvent.click(domainsLink)
+ expect(screen.getByText('DomainsPage')).toBeInTheDocument()
+ })
+
+ it('должен переходить на страницу туннелей при клике на "Перенаправление"', () => {
+ renderLayout('/')
+ const tunnelsLink = screen.getByText('Перенаправление')
+ fireEvent.click(tunnelsLink)
+ expect(screen.getByText('TunnelsPage')).toBeInTheDocument()
+ })
+
+ it('должен переходить на страницу настроек при клике на "Настройки"', () => {
+ renderLayout('/')
+ const settingsLink = screen.getByText('Настройки')
+ fireEvent.click(settingsLink)
+ expect(screen.getByText('SettingsPage')).toBeInTheDocument()
+ })
+
+ it('должен отображать Footer', () => {
+ renderLayout()
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument()
+ })
+
+ it('должен иметь правильную структуру с main и Toolbar', () => {
+ renderLayout()
+ const main = screen.getByTestId('outlet').closest('main')
+ expect(main).toBeInTheDocument()
+ })
+
+ it('должен иметь правильные иконки для пунктов меню', () => {
+ renderLayout()
+ expect(screen.getByTestId('icon-People')).toBeInTheDocument()
+ expect(screen.getByTestId('icon-Dns')).toBeInTheDocument()
+ expect(screen.getByTestId('icon-SwapHoriz')).toBeInTheDocument()
+ expect(screen.getByTestId('icon-Settings')).toBeInTheDocument()
+ })
+})
diff --git a/client/test/mocks.tsx b/client/test/mocks.tsx
new file mode 100644
index 0000000..69578e4
--- /dev/null
+++ b/client/test/mocks.tsx
@@ -0,0 +1,26 @@
+import { vi } from 'vitest'
+
+// Мок для window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+})
+
+// Мок для scrollIntoView
+Element.prototype.scrollIntoView = vi.fn()
+
+// Мок для IntersectionObserver
+window.IntersectionObserver = vi.fn(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}))
diff --git a/client/test/pages/LoginPage.spec.tsx b/client/test/pages/LoginPage.spec.tsx
new file mode 100644
index 0000000..35c2474
--- /dev/null
+++ b/client/test/pages/LoginPage.spec.tsx
@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import LoginPage from '../../src/pages/LoginPage'
+import { ThemeProvider } from '../../src/ThemeContext'
+import { AuthProvider } from '../../src/auth/AuthContext'
+
+const mockNavigate = vi.fn()
+const mockLogin = vi.fn()
+
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom')
+ return {
+ ...(actual as object),
+ useNavigate: () => mockNavigate,
+ }
+})
+
+vi.mock('../../src/auth/AuthContext', async () => {
+ const actual = await vi.importActual('../../src/auth/AuthContext')
+ return {
+ ...(actual as object),
+ useAuth: () => ({ login: mockLogin }),
+ }
+})
+
+vi.mock('../../src/api', () => ({
+ default: {
+ post: vi.fn(),
+ },
+}))
+
+const renderLoginPage = () => {
+ return render(
+
+
+
+
+
+
+
+ )
+}
+
+describe('LoginPage', () => {
+ beforeEach(() => {
+ mockNavigate.mockClear()
+ mockLogin.mockClear()
+ vi.clearAllMocks()
+ })
+
+ it('должен отображать заголовок с версией', () => {
+ renderLoginPage()
+ expect(screen.getByText(/Вход в 3DP-MANAGER/)).toBeInTheDocument()
+ })
+
+ it('должен отображать поля ввода логина и пароля', () => {
+ renderLoginPage()
+ expect(screen.getByLabelText('Логин')).toBeInTheDocument()
+ expect(screen.getByLabelText('Пароль')).toBeInTheDocument()
+ })
+
+ it('должен отображать кнопку "Войти"', () => {
+ renderLoginPage()
+ expect(screen.getByText('Войти')).toBeInTheDocument()
+ })
+
+ it('должен позволять вводить текст в поля', () => {
+ renderLoginPage()
+ const loginField = screen.getByLabelText('Логин')
+ const passwordField = screen.getByLabelText('Пароль')
+
+ fireEvent.change(loginField, { target: { value: 'admin' } })
+ fireEvent.change(passwordField, { target: { value: 'password123' } })
+
+ expect(loginField).toHaveValue('admin')
+ expect(passwordField).toHaveValue('password123')
+ })
+
+ it('должен показывать ошибку при неверных учетных данных', async () => {
+ const api = await import('../../src/api')
+ vi.mocked(api.default.post).mockRejectedValue({ response: { status: 401 } })
+
+ renderLoginPage()
+
+ fireEvent.change(screen.getByLabelText('Логин'), { target: { value: 'admin' } })
+ fireEvent.change(screen.getByLabelText('Пароль'), { target: { value: 'wrong' } })
+ fireEvent.click(screen.getByText('Войти'))
+
+ await waitFor(() => {
+ expect(screen.getByText('Неверный логин или пароль')).toBeInTheDocument()
+ })
+ })
+
+ it('должен выполнять навигацию на главную при успешном входе', async () => {
+ const api = await import('../../src/api')
+ vi.mocked(api.default.post).mockResolvedValue({ data: { access_token: 'fake-token' } })
+
+ renderLoginPage()
+
+ fireEvent.change(screen.getByLabelText('Логин'), { target: { value: 'admin' } })
+ fireEvent.change(screen.getByLabelText('Пароль'), { target: { value: 'password' } })
+ fireEvent.click(screen.getByText('Войти'))
+
+ await waitFor(() => {
+ expect(mockLogin).toHaveBeenCalledWith('fake-token')
+ expect(mockNavigate).toHaveBeenCalledWith('/')
+ })
+ })
+})
diff --git a/client/test/pages/NotFoundPage.spec.tsx b/client/test/pages/NotFoundPage.spec.tsx
new file mode 100644
index 0000000..648a27d
--- /dev/null
+++ b/client/test/pages/NotFoundPage.spec.tsx
@@ -0,0 +1,44 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import NotFoundPage from '../../src/pages/NotFoundPage'
+
+const mockNavigate = vi.fn()
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom')
+ return {
+ ...(actual as object),
+ useNavigate: () => mockNavigate,
+ }
+})
+
+const renderNotFoundPage = () => {
+ return render(
+
+
+
+ )
+}
+
+describe('NotFoundPage', () => {
+ it('должен отображать код ошибки 404', () => {
+ renderNotFoundPage()
+ expect(screen.getByText('404')).toBeInTheDocument()
+ })
+
+ it('должен отображать сообщение "Страница не найдена"', () => {
+ renderNotFoundPage()
+ expect(screen.getByText('Страница не найдена')).toBeInTheDocument()
+ })
+
+ it('должен иметь кнопку "На главную"', () => {
+ renderNotFoundPage()
+ expect(screen.getByText('На главную')).toBeInTheDocument()
+ })
+
+ it('должен выполнять навигацию на главную при клике на кнопку', () => {
+ renderNotFoundPage()
+ fireEvent.click(screen.getByText('На главную'))
+ expect(mockNavigate).toHaveBeenCalledWith('/')
+ })
+})
diff --git a/client/test/pages/SettingsPage.spec.tsx b/client/test/pages/SettingsPage.spec.tsx
new file mode 100644
index 0000000..22b0e19
--- /dev/null
+++ b/client/test/pages/SettingsPage.spec.tsx
@@ -0,0 +1,651 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import SettingsPage from '../../src/pages/SettingsPage'
+import { ThemeProvider } from '../../src/ThemeContext'
+import { AuthProvider } from '../../src/auth/AuthContext'
+
+const mockGet = vi.fn()
+const mockPost = vi.fn()
+const mockPut = vi.fn()
+
+vi.mock('../../src/api', () => ({
+ default: {
+ get: (...args: unknown[]) => mockGet(...args),
+ post: (...args: unknown[]) => mockPost(...args),
+ put: (...args: unknown[]) => mockPut(...args),
+ },
+}))
+
+// Хелпер для настройки мока get по умолчанию
+const setupMockGet = (overrides?: { settings?: Record, subscriptions?: unknown[] }) => {
+ mockGet.mockImplementation((url: string) => {
+ if (url === '/settings') return Promise.resolve({ data: overrides?.settings || {} })
+ if (url === '/subscriptions') return Promise.resolve({ data: overrides?.subscriptions || [] })
+ return Promise.resolve({ data: {} })
+ })
+}
+
+const renderSettingsPage = () => {
+ return render(
+
+
+
+
+
+
+
+ )
+}
+
+describe('SettingsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Рендеринг', () => {
+ it('должен рендериться с заголовком', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Настройки утилиты')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать секцию панели 3x-ui', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Панель 3x-ui')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать секцию генерации инбаундов', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Генерация инбаундов')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать поля ввода для 3x-ui', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('URL панели')).toBeInTheDocument()
+ expect(screen.getByLabelText('Логин 3x-ui')).toBeInTheDocument()
+ expect(screen.getByLabelText('Пароль 3x-ui')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать поле интервала генерации', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Интервал генерации')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать пресеты интервалов', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Сутки')).toBeInTheDocument()
+ expect(screen.getByText('3 дня')).toBeInTheDocument()
+ expect(screen.getByText('Неделя')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Загрузка настроек', () => {
+ it('должен загружать настройки при монтировании', async () => {
+ const mockSettings = {
+ xui_url: 'https://test.com:2053',
+ xui_login: 'admin',
+ xui_password: 'password',
+ rotation_interval: '60',
+ rotation_status: 'active',
+ last_rotation_timestamp: '1234567890',
+ }
+ setupMockGet({ settings: mockSettings })
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/settings')
+ })
+ })
+
+ it('должен загружать подписки при монтировании', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
+ ]
+ mockGet.mockImplementation((url: string) => {
+ if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
+ return Promise.resolve({ data: {} })
+ })
+
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(mockGet).toHaveBeenCalledWith('/subscriptions')
+ })
+ })
+ })
+
+ describe('Изменение полей', () => {
+ it('должен позволять изменять URL панели', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ const urlField = await screen.findByLabelText('URL панели')
+ fireEvent.change(urlField, { target: { value: 'https://new-url.com:2053' } })
+
+ expect(urlField).toHaveValue('https://new-url.com:2053')
+ })
+
+ it('должен позволять изменять логин 3x-ui', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ const loginField = await screen.findByLabelText('Логин 3x-ui')
+ fireEvent.change(loginField, { target: { value: 'newadmin' } })
+
+ expect(loginField).toHaveValue('newadmin')
+ })
+
+ it('должен позволять изменять пароль 3x-ui', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ const passwordField = await screen.findByLabelText('Пароль 3x-ui')
+ fireEvent.change(passwordField, { target: { value: 'newpassword' } })
+
+ expect(passwordField).toHaveValue('newpassword')
+ })
+ })
+
+ describe('Пресеты интервалов', () => {
+ it('должен отображать пресеты интервалов', async () => {
+ setupMockGet({ settings: { rotation_interval: '30' } })
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Сутки')).toBeInTheDocument()
+ expect(screen.getByText('3 дня')).toBeInTheDocument()
+ expect(screen.getByText('Неделя')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Сохранение настроек подключения', () => {
+ it('должен показывать ошибку при пустых полях', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ const saveButton = await screen.findByText('Сохранить подключение')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByText(/Заполните все поля/i)).toBeInTheDocument()
+ })
+ })
+
+ it('должен сохранять настройки при валидных данных', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: {} })
+
+ renderSettingsPage()
+
+ const urlField = await screen.findByLabelText('URL панели')
+ const loginField = screen.getByLabelText('Логин 3x-ui')
+ const passwordField = screen.getByLabelText('Пароль 3x-ui')
+
+ fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
+ fireEvent.change(loginField, { target: { value: 'admin' } })
+ fireEvent.change(passwordField, { target: { value: 'password' } })
+
+ const saveButton = screen.getByText('Сохранить подключение')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/settings', expect.objectContaining({
+ xui_url: 'https://test.com:2053',
+ xui_login: 'admin',
+ xui_password: 'password',
+ }))
+ })
+ })
+
+ it('должен показывать сообщение об успехе после сохранения', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: {} })
+
+ renderSettingsPage()
+
+ const urlField = await screen.findByLabelText('URL панели')
+ const loginField = screen.getByLabelText('Логин 3x-ui')
+ const passwordField = screen.getByLabelText('Пароль 3x-ui')
+
+ fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
+ fireEvent.change(loginField, { target: { value: 'admin' } })
+ fireEvent.change(passwordField, { target: { value: 'password' } })
+
+ const saveButton = screen.getByText('Сохранить подключение')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Настройки сохранены!')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Проверка подключения', () => {
+ it('должен проверять подключение при клике на кнопку "Проверить"', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: { success: true } })
+
+ renderSettingsPage()
+
+ const urlField = await screen.findByLabelText('URL панели')
+ const loginField = screen.getByLabelText('Логин 3x-ui')
+ const passwordField = screen.getByLabelText('Пароль 3x-ui')
+
+ fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
+ fireEvent.change(loginField, { target: { value: 'admin' } })
+ fireEvent.change(passwordField, { target: { value: 'password' } })
+
+ const checkButton = screen.getByText('Проверить')
+ fireEvent.click(checkButton)
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/settings/check', expect.objectContaining({
+ xui_url: 'https://test.com:2053',
+ xui_login: 'admin',
+ xui_password: 'password',
+ }))
+ })
+ })
+
+ it('должен показывать успех при успешной проверке', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: { success: true } })
+
+ renderSettingsPage()
+
+ const urlField = await screen.findByLabelText('URL панели')
+ const loginField = screen.getByLabelText('Логин 3x-ui')
+ const passwordField = screen.getByLabelText('Пароль 3x-ui')
+
+ fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
+ fireEvent.change(loginField, { target: { value: 'admin' } })
+ fireEvent.change(passwordField, { target: { value: 'password' } })
+
+ const checkButton = screen.getByText('Проверить')
+ fireEvent.click(checkButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Подключение успешно!')).toBeInTheDocument()
+ })
+ })
+
+ it('должен показывать ошибку при неудачной проверке', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: { success: false } })
+
+ renderSettingsPage()
+
+ const urlField = await screen.findByLabelText('URL панели')
+ const loginField = screen.getByLabelText('Логин 3x-ui')
+ const passwordField = screen.getByLabelText('Пароль 3x-ui')
+
+ fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
+ fireEvent.change(loginField, { target: { value: 'admin' } })
+ fireEvent.change(passwordField, { target: { value: 'wrong' } })
+
+ const checkButton = screen.getByText('Проверить')
+ fireEvent.click(checkButton)
+
+ await waitFor(() => {
+ expect(screen.getByText(/Ошибка/i)).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Сохранение интервала', () => {
+ it('должен сохранять интервал при клике на кнопку', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: {} })
+
+ renderSettingsPage()
+
+ const intervalField = await screen.findByLabelText('Интервал генерации')
+ fireEvent.change(intervalField, { target: { value: '120' } })
+
+ const saveButton = screen.getByText('Применить интервал')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/settings', {
+ rotation_interval: '120',
+ })
+ })
+ })
+
+ it('должен показывать сообщение об успехе после сохранения интервала', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: {} })
+
+ renderSettingsPage()
+
+ const intervalField = await screen.findByLabelText('Интервал генерации')
+ fireEvent.change(intervalField, { target: { value: '120' } })
+
+ const saveButton = screen.getByText('Применить интервал')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Интервал генерации применён!')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Принудительная ротация', () => {
+ it('должен показывать диалог подтверждения при клике на "Сгенерировать сейчас"', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ const rotateButton = await screen.findByText('Сгенерировать сейчас')
+ fireEvent.click(rotateButton)
+
+ await waitFor(() => {
+ expect(screen.getByText(/ВНИМАНИЕ/i)).toBeInTheDocument()
+ })
+ })
+
+ it('должен выполнять ротацию при подтверждении', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: { success: true, message: 'Ротация выполнена' } })
+
+ renderSettingsPage()
+
+ const rotateButton = await screen.findByText('Сгенерировать сейчас')
+ fireEvent.click(rotateButton)
+
+ const confirmButton = await screen.findByText('Продолжить')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/rotation/rotate-all')
+ })
+ })
+ })
+
+ describe('Управление авторотацией подписок', () => {
+ it('должен отображать список подписок с чекбоксами', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
+ ]
+ mockGet.mockImplementation((url: string) => {
+ if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
+ return Promise.resolve({ data: {} })
+ })
+
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Sub')).toBeInTheDocument()
+ })
+ })
+
+ it('должен переключать авторотацию при клике на чекбокс', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
+ ]
+ mockGet.mockImplementation((url: string) => {
+ if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
+ return Promise.resolve({ data: {} })
+ })
+ mockPut.mockResolvedValue({ data: {} })
+
+ renderSettingsPage()
+
+ const checkbox = await screen.findByRole('checkbox')
+ fireEvent.click(checkbox)
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.anything())
+ })
+ })
+
+ it('должен показывать сообщение при включении авторотации', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: false }
+ ]
+ mockGet.mockImplementation((url: string) => {
+ if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
+ return Promise.resolve({ data: {} })
+ })
+ mockPut.mockResolvedValue({ data: {} })
+
+ renderSettingsPage()
+
+ const checkbox = await screen.findByRole('checkbox')
+ fireEvent.click(checkbox)
+
+ await waitFor(() => {
+ expect(screen.getByText('Авторотация включена')).toBeInTheDocument()
+ })
+ })
+
+ it('должен выполнять ручную ротацию при клике на кнопку обновления', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
+ ]
+ mockGet.mockImplementation((url: string) => {
+ if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
+ return Promise.resolve({ data: {} })
+ })
+ mockPost.mockResolvedValue({ data: { message: 'Ротация выполнена' } })
+
+ renderSettingsPage()
+
+ const refreshButton = await screen.findByRole('button', { name: /Обновить подписку вручную/i })
+ fireEvent.click(refreshButton)
+
+ const confirmButton = await screen.findByText('Продолжить')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/rotation/rotate-one/1')
+ })
+ })
+
+ it('должен выполнять массовое включение авторотации', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: false }
+ ]
+ mockGet.mockImplementation((url: string) => {
+ if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
+ return Promise.resolve({ data: {} })
+ })
+ mockPut.mockResolvedValue({ data: { message: 'Настройки обновлены' } })
+
+ renderSettingsPage()
+
+ const enableAllButton = await screen.findByText('Включить для всех')
+ fireEvent.click(enableAllButton)
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.objectContaining({
+ enabled: true,
+ }))
+ })
+ })
+
+ it('должен выполнять массовое выключение авторотации', async () => {
+ const mockSubs = [
+ { id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
+ ]
+ mockGet.mockImplementation((url: string) => {
+ if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
+ return Promise.resolve({ data: {} })
+ })
+ mockPut.mockResolvedValue({ data: { message: 'Настройки обновлены' } })
+
+ renderSettingsPage()
+
+ const disableAllButton = await screen.findByText('Выключить для всех')
+ fireEvent.click(disableAllButton)
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.objectContaining({
+ enabled: false,
+ }))
+ })
+ })
+ })
+
+ describe('Обновление профиля администратора', () => {
+ it('должен позволять изменять логин администратора', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ const loginField = await screen.findByLabelText('Логин администратора')
+ fireEvent.change(loginField, { target: { value: 'newadmin' } })
+
+ expect(loginField).toHaveValue('newadmin')
+ })
+
+ it('должен позволять изменять пароль администратора', async () => {
+ setupMockGet()
+ renderSettingsPage()
+
+ const passwordField = await screen.findByLabelText('Новый пароль')
+ fireEvent.change(passwordField, { target: { value: 'newpassword' } })
+
+ expect(passwordField).toHaveValue('newpassword')
+ })
+
+ it('должен сохранять профиль администратора', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: {} })
+
+ renderSettingsPage()
+
+ const loginField = await screen.findByLabelText('Логин администратора')
+ const passwordField = screen.getByLabelText('Новый пароль')
+
+ fireEvent.change(loginField, { target: { value: 'newadmin' } })
+ fireEvent.change(passwordField, { target: { value: 'newpassword' } })
+
+ const saveButton = screen.getByText('Обновить профиль')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/auth/update-profile', expect.objectContaining({
+ login: 'newadmin',
+ password: 'newpassword',
+ }))
+ })
+ })
+
+ it('должен показывать сообщение об успехе после обновления профиля', async () => {
+ setupMockGet()
+ mockPost.mockResolvedValue({ data: {} })
+
+ renderSettingsPage()
+
+ const loginField = await screen.findByLabelText('Логин администратора')
+ const passwordField = screen.getByLabelText('Новый пароль')
+
+ fireEvent.change(loginField, { target: { value: 'newadmin' } })
+ fireEvent.change(passwordField, { target: { value: 'newpassword' } })
+
+ const saveButton = screen.getByText('Обновить профиль')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Профиль администратора обновлен!')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Пауза/возобновление ротации', () => {
+ it('должен отображать статус "Активен" при active статусе', async () => {
+ setupMockGet({ settings: { rotation_status: 'active' } })
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Активен')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать статус "Остановлен" при stopped статусе', async () => {
+ setupMockGet({ settings: { rotation_status: 'stopped' } })
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Остановлен')).toBeInTheDocument()
+ })
+ })
+
+ it('должен переключать статус при клике на кнопку паузы', async () => {
+ setupMockGet({ settings: { rotation_status: 'active' } })
+ mockPost.mockResolvedValue({ data: {} })
+
+ renderSettingsPage()
+
+ const pauseButton = await screen.findByRole('button', { name: 'Поставить на паузу' })
+ fireEvent.click(pauseButton)
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/settings', expect.objectContaining({
+ rotation_status: 'stopped',
+ }))
+ })
+ })
+ })
+
+ describe('Отображение дат ротации', () => {
+ it('должен отображать дату последней ротации', async () => {
+ setupMockGet({ settings: { last_rotation_timestamp: '1234567890' } })
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Последняя генерация')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать дату следующей ротации', async () => {
+ setupMockGet({ settings: {
+ rotation_status: 'active',
+ last_rotation_timestamp: '1234567890',
+ rotation_interval: '60'
+ } })
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Следующая генерация')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать "Пауза" для следующей ротации при stopped статусе', async () => {
+ setupMockGet({ settings: { rotation_status: 'stopped' } })
+ renderSettingsPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Пауза')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/client/test/pages/TunnelsPage.spec.tsx b/client/test/pages/TunnelsPage.spec.tsx
new file mode 100644
index 0000000..ad49e08
--- /dev/null
+++ b/client/test/pages/TunnelsPage.spec.tsx
@@ -0,0 +1,209 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import TunnelsPage from '../../src/pages/TunnelsPage'
+import api from '../../src/api'
+
+vi.mock('../../src/api')
+
+describe('TunnelsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(api.get).mockResolvedValue({ data: [] })
+ vi.mocked(api.post).mockResolvedValue({ data: {} })
+ vi.mocked(api.delete).mockResolvedValue({ data: {} })
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const renderPage = async () => {
+ const result = render(
+
+
+
+ )
+ // Ждём завершения первоначальной загрузки данных
+ await waitFor(() => {
+ expect(screen.getByText('Relay серверы')).toBeInTheDocument()
+ })
+ return result
+ }
+
+ it('должен рендериться с заголовком', async () => {
+ await renderPage()
+ expect(screen.getByText('Relay серверы')).toBeInTheDocument()
+ })
+
+ it('должен отображать кнопку "Добавить"', async () => {
+ await renderPage()
+ expect(screen.getByText('Добавить')).toBeInTheDocument()
+ })
+
+ it('должен открывать диалог создания при клике на "Добавить"', async () => {
+ await renderPage()
+ const addButton = screen.getByText('Добавить')
+ fireEvent.click(addButton)
+
+ expect(screen.getByText('Новый редирект сервер')).toBeInTheDocument()
+ })
+
+ it('должен закрывать диалог создания при клике на отмену', async () => {
+ await renderPage()
+ const addButton = screen.getByText('Добавить')
+ fireEvent.click(addButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Новый редирект сервер')).toBeInTheDocument()
+ })
+
+ const cancelButton = screen.getByText('Отмена')
+ fireEvent.click(cancelButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText('Новый редирект сервер')).not.toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать поля формы в диалоге создания', async () => {
+ await renderPage()
+ const addButton = screen.getByText('Добавить')
+ fireEvent.click(addButton)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Название')).toBeInTheDocument()
+ })
+ expect(screen.getByLabelText('IP адрес')).toBeInTheDocument()
+ expect(screen.getByLabelText('SSH Порт')).toBeInTheDocument()
+ expect(screen.getByLabelText('SSH User')).toBeInTheDocument()
+ })
+
+ it('должен переключать режим аутентификации между паролем и ключом', async () => {
+ await renderPage()
+ const addButton = screen.getByText('Добавить')
+ fireEvent.click(addButton)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('SSH Пароль')).toBeInTheDocument()
+ })
+
+ const keyRadio = screen.getByLabelText('По SSH ключу')
+ fireEvent.click(keyRadio)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/SSH Private Key/i)).toBeInTheDocument()
+ })
+ })
+
+ it('должен показывать сообщение "Нет серверов" при пустом списке', async () => {
+ await renderPage()
+ expect(screen.getByText('Нет серверов')).toBeInTheDocument()
+ })
+
+ it('должен отображать список туннелей', async () => {
+ vi.mocked(api.get).mockResolvedValue({
+ data: [
+ { id: 1, name: 'Test Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
+ ]
+ })
+
+ await renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Tunnel')).toBeInTheDocument()
+ })
+ expect(screen.getByText('192.168.1.1')).toBeInTheDocument()
+ })
+
+ it('должен отображать статус "Активен" для установленного туннеля', async () => {
+ vi.mocked(api.get).mockResolvedValue({
+ data: [
+ { id: 1, name: 'Active Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
+ ]
+ })
+
+ await renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Активен')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать статус "Не настроен" для неустановленного туннеля', async () => {
+ vi.mocked(api.get).mockResolvedValue({
+ data: [
+ { id: 1, name: 'Inactive Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: false }
+ ]
+ })
+
+ await renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Не настроен')).toBeInTheDocument()
+ })
+ })
+
+ it('должен отображать кнопку "Установить" для неустановленного туннеля', async () => {
+ vi.mocked(api.get).mockResolvedValue({
+ data: [
+ { id: 1, name: 'Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: false }
+ ]
+ })
+
+ await renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Установить')).toBeInTheDocument()
+ })
+ })
+
+ it('должен открывать диалог подтверждения при удалении туннеля', async () => {
+ vi.mocked(api.get).mockResolvedValue({
+ data: [
+ { id: 1, name: 'Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
+ ]
+ })
+
+ await renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Tunnel')).toBeInTheDocument()
+ })
+
+ const deleteButton = screen.getAllByTestId('icon-Delete')[0]
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Удалить сервер из списка?')).toBeInTheDocument()
+ })
+ })
+
+ it('должен закрывать диалог подтверждения при клике на "Отмена"', async () => {
+ vi.mocked(api.get).mockResolvedValue({
+ data: [
+ { id: 1, name: 'Tunnel', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
+ ]
+ })
+
+ await renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByText('Tunnel')).toBeInTheDocument()
+ })
+
+ const deleteButton = screen.getAllByTestId('icon-Delete')[0]
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Удалить сервер из списка?')).toBeInTheDocument()
+ })
+
+ const cancelButton = screen.getByText('Отмена')
+ fireEvent.click(cancelButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText('Удалить сервер из списка?')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/client/test/setup.ts b/client/test/setup.ts
new file mode 100644
index 0000000..ad1232d
--- /dev/null
+++ b/client/test/setup.ts
@@ -0,0 +1,126 @@
+import '@testing-library/jest-dom'
+import { cleanup } from '@testing-library/react'
+import { afterEach, vi } from 'vitest'
+import './mocks'
+
+// Очищать DOM после каждого теста
+afterEach(() => {
+ cleanup()
+})
+
+// Подавляем шумные console.log/console.error во время тестов
+// Оставляем только важные ошибки через test.skip()
+const originalConsoleLog = console.log
+const originalConsoleError = console.error
+
+beforeAll(() => {
+ // Фильтруем шумные логи от приложений
+ console.log = (...args) => {
+ const message = args.join(' ')
+ // Пропускаем логи от компонентов которые шумят
+ if (
+ message.includes('[Tunnels]') ||
+ message.includes('[Settings]') ||
+ message.includes('[API]') ||
+ message.includes('[Login]') ||
+ message.includes('[Rotation]')
+ ) {
+ return
+ }
+ originalConsoleLog(...args)
+ }
+
+ // Подавляем console.error для известных предупреждений React
+ console.error = (...args) => {
+ const message = args.join(' ')
+ // Пропускаем предупреждения act(...) - они не критичны
+ if (
+ message.includes('act(...)') ||
+ message.includes('An update to')
+ ) {
+ return
+ }
+ originalConsoleError(...args)
+ }
+})
+
+afterAll(() => {
+ // Восстанавливаем console после всех тестов
+ console.log = originalConsoleLog
+ console.error = originalConsoleError
+})
+
+// Мок для MUI icons-material - используем vi.mock с factory
+vi.mock('@mui/icons-material', async () => {
+ const React = await import('react')
+
+ const createIconMock = (name: string) => {
+ const IconMock = (props: Record) => {
+ return React.createElement('span', {
+ 'data-testid': `icon-${name}`,
+ ...props
+ }, name)
+ }
+ IconMock.displayName = name
+ return IconMock
+ }
+
+ // Создаем мок для всех иконок
+ const mock: Record = {}
+ const icons = [
+ 'GitHub', 'YouTube', 'Telegram', 'Brightness7', 'Brightness4', 'BrightnessAuto',
+ 'Logout', 'HelpOutline', 'Menu', 'People', 'Settings', 'Dns', 'SwapHoriz',
+ 'Delete', 'Add', 'Terminal', 'CheckCircle', 'Error', 'LinkIcon', 'OpenInNew',
+ 'ContentCopy', 'Router', 'Edit', 'MoreVert', 'Remove', 'Refresh', 'Search',
+ 'FilterList', 'Warning', 'Info', 'Close', 'Check', 'ArrowDownward', 'ArrowUpward',
+ 'MoreHoriz', 'ContentPaste', 'QrCode', 'Usb', 'VpnKey', 'Security', 'Speed',
+ 'Timeline', 'Assessment', 'SettingsApplications', 'CloudDownload', 'CloudUpload',
+ 'Folder', 'FileCopy', 'Save', 'Print', 'DeleteOutline', 'Restore', 'History',
+ 'Schedule', 'AccessTime', 'Today', 'Event', 'Notifications', 'AccountCircle',
+ 'Person', 'Group', 'Public', 'Language', 'Translate', 'Star', 'Favorite',
+ 'Home', 'LocationOn', 'Place', 'Email', 'Phone', 'Chat', 'Message', 'Forum',
+ 'Share', 'Send', 'Inbox', 'Drafts', 'Mail', 'Markunread', 'Lock', 'LockOpen',
+ 'Unlock', 'Visibility', 'VisibilityOff', 'ToggleOn', 'ToggleOff',
+ 'RadioButtonChecked', 'RadioButtonUnchecked', 'CheckBox', 'CheckBoxOutlineBlank',
+ 'PlusOne', 'ThumbUp', 'ThumbDown', 'Whatshot', 'FavoriteBorder', 'StarBorder',
+ 'Bookmark', 'BookmarkBorder', 'Bookmarks', 'TurnedIn', 'TurnedInNot', 'Label',
+ 'LabelImportant', 'Grade', 'Done', 'Clear', 'Block', 'Stop', 'Pause', 'PlayArrow',
+ 'FastForward', 'FastRewind', 'SkipNext', 'SkipPrevious', 'FiberManualRecord',
+ 'Circle', 'NavigateNext', 'NavigateBefore', 'ChevronRight', 'ChevronLeft',
+ 'ExpandMore', 'ExpandLess', 'UnfoldMore', 'UnfoldLess', 'ArrowRight', 'ArrowLeft',
+ 'ArrowBack', 'ArrowForward', 'ArrowDropDown', 'ArrowDropUp', 'Expand',
+ 'FileDownload', 'FileUpload', 'Attachment', 'Link', 'InsertLink', 'Photo',
+ 'Image', 'PictureAsPdf', 'ImageIcon', 'CameraAlt', 'Videocam', 'Movie',
+ 'MusicNote', 'Mic', 'VolumeUp', 'VolumeOff', 'Headset', 'Headphones', 'Speaker',
+ 'Radio', 'Podcasts', 'Tv', 'DesktopWindows', 'Laptop', 'Computer', 'Tablet',
+ 'Smartphone', 'PhoneIphone', 'PhoneAndroid', 'Devices', 'SmartDisplay', 'Monitor',
+ 'ScreenShare', 'StopScreenShare', 'PresentToAll', 'Cast', 'CastConnected',
+ 'Wifi', 'WifiOff', 'NetworkWifi', 'NetworkCell', 'SignalCellular4Bar',
+ 'SignalWifi4Bar', 'Bluetooth', 'BluetoothConnected', 'BluetoothDisabled',
+ 'GpsFixed', 'GpsNotFixed', 'LocationSearching', 'MyLocation', 'Navigation',
+ 'NearMe', 'Directions', 'DirectionsCar', 'DirectionsBus', 'DirectionsTrain',
+ 'DirectionsBike', 'DirectionsWalk', 'DirectionsRun', 'Flight', 'LocalAirport',
+ 'Hotel', 'Restaurant', 'LocalCafe', 'LocalBar', 'LocalPizza', 'BrunchDining',
+ 'DinnerDining', 'LunchDining', 'Nightlife', 'LocalHospital', 'LocalPharmacy',
+ 'ShoppingBag', 'ShoppingCart', 'ShoppingBasket', 'Store', 'Shop', 'Storefront',
+ 'LocalMall', 'AccountBalance', 'Business', 'CorporateFare', 'Work', 'MeetingRoom',
+ 'Gite', 'House', 'Cottage', 'Apartment', 'Villa', 'OtherHouses', 'Foundation',
+ 'Fence', 'Yard', 'Pool', 'HotTub', 'Spa', 'FitnessCenter', 'SportsGymnasium',
+ 'SportsBasketball', 'SportsFootball', 'SportsSoccer', 'SportsTennis',
+ 'SportsVolleyball', 'SportsBaseball', 'SportsCricket', 'SportsGolf', 'SportsHockey',
+ 'SportsMma', 'SportsMotorsports', 'SportsRugby', 'SportsScore', 'SportsHandball',
+ 'SportsKabaddi', 'Rowing', 'Surfing', 'Kitesurfing', 'Snowboarding',
+ 'DownhillSkiing', 'Snowshoeing', 'IceSkating', 'Curling', 'Sailing', 'Kayaking',
+ 'Rafting', 'ScubaDiving', 'Diving', 'Fishing', 'Hiking', 'RunningWithErrors',
+ 'PlayCircleFilled', 'PauseCircleFilled', 'RefreshTwoTone', 'SubdirectoryArrowRight',
+ 'SubdirectoryArrowLeft', 'SettingsInputComponent', 'SettingsInputComponentOutlined',
+ 'Dns',
+ ]
+
+ icons.forEach(name => {
+ mock[name] = createIconMock(name)
+ })
+
+ mock.default = createIconMock('DefaultIcon')
+ return mock
+})
diff --git a/client/test/test-utils.tsx b/client/test/test-utils.tsx
new file mode 100644
index 0000000..032644b
--- /dev/null
+++ b/client/test/test-utils.tsx
@@ -0,0 +1,40 @@
+/* eslint-disable react-refresh/only-export-components */
+import { RenderOptions, render } from '@testing-library/react'
+import { ReactElement, ReactNode } from 'react'
+import { ThemeProvider } from '../src/ThemeContext'
+import { AuthProvider } from '../src/auth/AuthContext'
+import { BrowserRouter } from 'react-router-dom'
+
+interface AllProvidersProps {
+ children: ReactNode
+}
+
+function AllProviders({ children }: AllProvidersProps) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+interface CustomRenderOptions extends Omit {
+ wrapper?: ReactElement
+}
+
+export function customRender(
+ ui: ReactElement,
+ options?: CustomRenderOptions
+) {
+ return render(ui, {
+ wrapper: AllProviders,
+ ...options,
+ })
+}
+
+// Переэкспортируем всё из @testing-library/react
+export * from '@testing-library/react'
+
+// Переопределяем render с нашими провайдерами
+export { customRender as render }
diff --git a/client/test/theme.spec.ts b/client/test/theme.spec.ts
new file mode 100644
index 0000000..7de33ad
--- /dev/null
+++ b/client/test/theme.spec.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect } from 'vitest'
+import { getDesignTokens } from '@/theme'
+
+describe('theme', () => {
+ describe('getDesignTokens', () => {
+ it('должен возвращать объект с palette, typography, shape и components для light mode', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens).toHaveProperty('palette')
+ expect(tokens).toHaveProperty('typography')
+ expect(tokens).toHaveProperty('shape')
+ expect(tokens).toHaveProperty('components')
+ })
+
+ it('должен возвращать объект с palette, typography, shape и components для dark mode', () => {
+ const tokens = getDesignTokens('dark')
+
+ expect(tokens).toHaveProperty('palette')
+ expect(tokens).toHaveProperty('typography')
+ expect(tokens).toHaveProperty('shape')
+ expect(tokens).toHaveProperty('components')
+ })
+
+ it('должен устанавливать правильный mode в palette', () => {
+ const lightTokens = getDesignTokens('light')
+ const darkTokens = getDesignTokens('dark')
+
+ expect(lightTokens.palette.mode).toBe('light')
+ expect(darkTokens.palette.mode).toBe('dark')
+ })
+
+ it('должен использовать lightPalette для light mode', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens.palette.primary.main).toBe('#1395de')
+ expect(tokens.palette.background.default).toBe('#f3f4f6')
+ expect(tokens.palette.background.paper).toBe('#ffffff')
+ })
+
+ it('должен использовать darkPalette для dark mode', () => {
+ const tokens = getDesignTokens('dark')
+
+ expect(tokens.palette.primary.main).toBe('#1395de')
+ expect(tokens.palette.background.default).toBe('#0B0F19')
+ expect(tokens.palette.background.paper).toBe('#111827')
+ })
+
+ it('должен устанавливать fontFamily', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens.typography.fontFamily).toBe('"Inter", "Roboto", "Helvetica", "Arial", sans-serif')
+ })
+
+ it('должен устанавливать fontWeight для заголовков', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens.typography.h1.fontWeight).toBe(700)
+ expect(tokens.typography.h2.fontWeight).toBe(700)
+ expect(tokens.typography.h3.fontWeight).toBe(600)
+ expect(tokens.typography.h4.fontWeight).toBe(600)
+ expect(tokens.typography.h5.fontWeight).toBe(600)
+ expect(tokens.typography.h6.fontWeight).toBe(600)
+ })
+
+ it('должен устанавливать textTransform none для кнопок', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens.typography.button.textTransform).toBe('none')
+ expect(tokens.typography.button.fontWeight).toBe(600)
+ })
+
+ it('должен устанавливать borderRadius 12', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens.shape.borderRadius).toBe(12)
+ })
+
+ it('должен настраивать MuiCssBaseline для кастомных скроллбаров', () => {
+ const lightTokens = getDesignTokens('light')
+ const darkTokens = getDesignTokens('dark')
+
+ expect(lightTokens.components.MuiCssBaseline).toBeDefined()
+ expect(darkTokens.components.MuiCssBaseline).toBeDefined()
+
+ // Проверяем что styleOverrides существуют
+ expect(lightTokens.components.MuiCssBaseline.styleOverrides).toBeDefined()
+ expect(darkTokens.components.MuiCssBaseline.styleOverrides).toBeDefined()
+ })
+
+ it('должен настраивать MuiButton с borderRadius 8', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens.components.MuiButton.styleOverrides.root.borderRadius).toBe(8)
+ expect(tokens.components.MuiButton.styleOverrides.root.boxShadow).toBe('none')
+ })
+
+ it('должен настраивать MuiPaper без backgroundImage', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens.components.MuiPaper.styleOverrides.root.backgroundImage).toBe('none')
+ })
+
+ it('должен настраивать MuiPaper с border в зависимости от mode', () => {
+ const lightTokens = getDesignTokens('light')
+ const darkTokens = getDesignTokens('dark')
+
+ expect(lightTokens.components.MuiPaper.styleOverrides.elevation1.border).toBe('1px solid #e5e7eb')
+ expect(darkTokens.components.MuiPaper.styleOverrides.elevation1.border).toBe('1px solid #374151')
+ })
+
+ it('должен настраивать MuiOutlinedInput с правильными borderColor', () => {
+ const lightTokens = getDesignTokens('light')
+ const darkTokens = getDesignTokens('dark')
+
+ expect(lightTokens.components.MuiOutlinedInput.styleOverrides.root['& .MuiOutlinedInput-notchedOutline'].borderColor).toBe('#e5e7eb')
+ expect(darkTokens.components.MuiOutlinedInput.styleOverrides.root['& .MuiOutlinedInput-notchedOutline'].borderColor).toBe('#374151')
+ })
+
+ it('должен настраивать MuiAppBar с backdropFilter', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens.components.MuiAppBar.styleOverrides.root.backdropFilter).toBe('blur(8px)')
+ expect(tokens.components.MuiAppBar.styleOverrides.root.boxShadow).toBe('none')
+ })
+
+ it('должен настраивать MuiTableRow без border у последнего элемента', () => {
+ const tokens = getDesignTokens('light')
+
+ expect(tokens.components.MuiTableRow.styleOverrides.root['&:last-child td'].borderBottom).toBe(0)
+ })
+ })
+})
diff --git a/client/test/utils/errorHandlers.spec.ts b/client/test/utils/errorHandlers.spec.ts
new file mode 100644
index 0000000..099cff3
--- /dev/null
+++ b/client/test/utils/errorHandlers.spec.ts
@@ -0,0 +1,82 @@
+import { describe, it, expect } from 'vitest'
+import {
+ isApiError,
+ getApiErrorMessage,
+ getApiErrorStatus,
+} from '@/utils/errorHandlers'
+
+describe('errorHandlers', () => {
+ describe('isApiError', () => {
+ it('должен возвращать true для API ошибки с response', () => {
+ const error = { response: { status: 400, data: { message: 'Error' } } }
+ expect(isApiError(error)).toBe(true)
+ })
+
+ it('должен возвращать true для API ошибки без response.data', () => {
+ const error = { response: {} }
+ expect(isApiError(error)).toBe(true)
+ })
+
+ it('должен возвращать false для null', () => {
+ expect(isApiError(null)).toBe(false)
+ })
+
+ it('должен возвращать false для строки', () => {
+ expect(isApiError('error')).toBe(false)
+ })
+
+ it('должен возвращать false для объекта без response', () => {
+ expect(isApiError({ message: 'Error' })).toBe(false)
+ })
+ })
+
+ describe('getApiErrorMessage', () => {
+ it('должен извлекать строковое сообщение из ошибки', () => {
+ const error = { response: { data: { message: 'Custom error' } } }
+ expect(getApiErrorMessage(error)).toBe('Custom error')
+ })
+
+ it('должен извлекать массив сообщений и объединять через точку с запятой', () => {
+ const error = { response: { data: { message: ['Error 1', 'Error 2'] } } }
+ expect(getApiErrorMessage(error)).toBe('Error 1; Error 2')
+ })
+
+ it('должен возвращать сообщение по умолчанию для обычной ошибки', () => {
+ expect(getApiErrorMessage('string error')).toBe('Произошла ошибка')
+ })
+
+ it('должен извлекать message из Error объекта', () => {
+ const error = new Error('Native error')
+ expect(getApiErrorMessage(error)).toBe('Native error')
+ })
+
+ it('должен использовать кастомное сообщение по умолчанию', () => {
+ expect(getApiErrorMessage(null, 'Custom default')).toBe('Custom default')
+ })
+
+ it('должен возвращать undefined message как сообщение по умолчанию', () => {
+ const error = { response: { data: { message: undefined } } }
+ expect(getApiErrorMessage(error)).toBe('Произошла ошибка')
+ })
+ })
+
+ describe('getApiErrorStatus', () => {
+ it('должен извлекать status код из ошибки', () => {
+ const error = { response: { status: 404 } }
+ expect(getApiErrorStatus(error)).toBe(404)
+ })
+
+ it('должен возвращать undefined для ошибки без status', () => {
+ const error = { response: {} }
+ expect(getApiErrorStatus(error)).toBeUndefined()
+ })
+
+ it('должен возвращать undefined для не API ошибки', () => {
+ expect(getApiErrorStatus('error')).toBeUndefined()
+ })
+
+ it('должен возвращать undefined для null', () => {
+ expect(getApiErrorStatus(null)).toBeUndefined()
+ })
+ })
+})
diff --git a/client/test/utils/logger.spec.ts b/client/test/utils/logger.spec.ts
new file mode 100644
index 0000000..27f3210
--- /dev/null
+++ b/client/test/utils/logger.spec.ts
@@ -0,0 +1,119 @@
+import { describe, it, expect, vi } from 'vitest'
+import { Logger } from '@/utils/logger'
+
+describe('Logger', () => {
+ describe('Logger.error', () => {
+ it('должен вызывать console.error с правильным форматом', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ Logger.error('Test error', 'TestModule')
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test error', '')
+ consoleSpy.mockRestore()
+ })
+
+ it('должен вызывать console.error с данными', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const data = { code: 500, message: 'Internal error' }
+ Logger.error('Test error', 'TestModule', data)
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test error', data)
+ consoleSpy.mockRestore()
+ })
+
+ it('должен использовать модуль по умолчанию "App"', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ Logger.error('Test error')
+ expect(consoleSpy).toHaveBeenCalledWith('[App] Test error', '')
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('Logger.warn', () => {
+ it('должен вызывать console.warn с правильным форматом', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ Logger.warn('Test warning', 'TestModule')
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test warning', '')
+ consoleSpy.mockRestore()
+ })
+
+ it('должен вызывать console.warn с данными', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const data = { code: 400, field: 'email' }
+ Logger.warn('Test warning', 'TestModule', data)
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test warning', data)
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('Logger.info', () => {
+ it('должен вызывать console.info с правильным форматом', () => {
+ const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
+ Logger.info('Test info', 'TestModule')
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test info', '')
+ consoleSpy.mockRestore()
+ })
+
+ it('должен вызывать console.info с данными', () => {
+ const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
+ const data = { user: 'admin', action: 'login' }
+ Logger.info('Test info', 'TestModule', data)
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test info', data)
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('Logger.debug', () => {
+ it('должен вызывать console.log с правильным форматом', () => {
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ Logger.debug('Test debug', 'TestModule')
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test debug', '')
+ consoleSpy.mockRestore()
+ })
+
+ it('должен вызывать console.log с данными', () => {
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const data = { state: 'loading', progress: 50 }
+ Logger.debug('Test debug', 'TestModule', data)
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test debug', data)
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('Logger.verbose', () => {
+ it('должен вызывать console.log с правильным форматом', () => {
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ Logger.verbose('Test verbose', 'TestModule')
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test verbose', '')
+ consoleSpy.mockRestore()
+ })
+
+ it('должен вызывать console.log с данными', () => {
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const data = { detailed: 'trace', step: 3 }
+ Logger.verbose('Test verbose', 'TestModule', data)
+ expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test verbose', data)
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('formatMessage', () => {
+ it('должен форматировать сообщение без данных', () => {
+ const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
+ Logger.info('Simple message', 'Module')
+ expect(consoleSpy).toHaveBeenCalledWith('[Module] Simple message', '')
+ consoleSpy.mockRestore()
+ })
+
+ it('должен форматировать сообщение с объектом данных', () => {
+ const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
+ Logger.info('Message with data', 'Module', { key: 'value' })
+ expect(consoleSpy).toHaveBeenCalledWith('[Module] Message with data', { key: 'value' })
+ consoleSpy.mockRestore()
+ })
+
+ it('должен форматировать сообщение с массивом данных', () => {
+ const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
+ Logger.info('Message with array', 'Module', [1, 2, 3])
+ expect(consoleSpy).toHaveBeenCalledWith('[Module] Message with array', [1, 2, 3])
+ consoleSpy.mockRestore()
+ })
+ })
+})
diff --git a/client/test/utils/version.spec.ts b/client/test/utils/version.spec.ts
new file mode 100644
index 0000000..0f20b37
--- /dev/null
+++ b/client/test/utils/version.spec.ts
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'vitest'
+import { APP_VERSION } from '@/utils/version'
+
+describe('version', () => {
+ describe('APP_VERSION', () => {
+ it('должен быть строкой', () => {
+ expect(typeof APP_VERSION).toBe('string')
+ })
+
+ it('должен соответствовать формату семантического версионирования', () => {
+ const semverRegex = /^\d+\.\d+\.\d+$/
+ expect(APP_VERSION).toMatch(semverRegex)
+ })
+
+ it('должен быть непустой строкой', () => {
+ expect(APP_VERSION.length).toBeGreaterThan(0)
+ })
+ })
+})
diff --git a/client/vite.config.ts b/client/vite.config.ts
index eca6e2f..c0ed45d 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -2,5 +2,17 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
- plugins: [react()]
+ plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './test/setup.ts',
+ pool: 'forks',
+ poolOptions: {
+ forks: {
+ maxForks: 4,
+ minForks: 1,
+ },
+ },
+ },
})
diff --git a/client/vitest.config.ts b/client/vitest.config.ts
new file mode 100644
index 0000000..69256e7
--- /dev/null
+++ b/client/vitest.config.ts
@@ -0,0 +1,47 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ port: 8080,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3100',
+ changeOrigin: true
+ },
+ '/bus': {
+ target: 'http://localhost:3100',
+ changeOrigin: true
+ }
+ }
+ },
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './test/setup.ts',
+ css: true,
+ singleThread: true,
+ env: {
+ VITE_LOG_LEVEL: 'verbose',
+ },
+ coverage: {
+ enabled: true,
+ provider: 'v8',
+ reporter: ['text', 'json', 'html', 'lcov'],
+ reportsDirectory: './coverage',
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: [
+ 'src/main.tsx',
+ 'src/**/*.d.ts',
+ 'src/types/**',
+ ],
+ },
+ },
+})