From 5720af9108be54ff8b2a33c303a7a8af3e026607 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Mon, 15 Jun 2026 11:29:48 +0800 Subject: [PATCH] test: add unit tests for various components and utilities - Created tests for formatBytes function to ensure correct formatting of byte values. - Implemented tests for InjectContext to validate resource injection and cleanup. - Added tests for logo-class helpers to verify platform alias mappings. - Developed tests for nezha-api functions to check API response handling. - Introduced tests for static geographic data to validate country coordinates and GeoJSON structure. - Created comprehensive tests for utility functions including date formatting and public note parsing. - Added tests for Server page to ensure correct rendering and functionality. - Implemented tests for simple pages including error handling and navigation. - Set up testing environment with Vitest and configured coverage reporting. --- .github/dependabot.yml | 10 +- .../workflows/auto-fix-lint-format-commit.yml | 32 +- .github/workflows/ci.yml | 8 +- .gitignore | 1 + package.json | 19 +- pnpm-lock.yaml | 912 ++++++++++++++++++ src/pages/ServerDetail.tsx | 7 +- src/test/app.test.tsx | 141 +++ src/test/components/RemainPercentBar.test.tsx | 22 + src/test/components/ThemeProvider.test.tsx | 144 +++ src/test/components/basic-components.test.tsx | 190 ++++ src/test/components/chart-primitives.test.tsx | 136 +++ src/test/components/dash-command.test.tsx | 92 ++ src/test/components/global-map.test.tsx | 175 ++++ src/test/components/header.test.tsx | 243 +++++ src/test/components/info-components.test.tsx | 218 +++++ .../components/navigation-and-footer.test.tsx | 158 +++ src/test/components/network-chart.test.tsx | 287 ++++++ src/test/components/server-cards.test.tsx | 148 +++ .../components/server-detail-chart.test.tsx | 290 ++++++ .../server-detail-components.test.tsx | 138 +++ src/test/components/service-tracker.test.tsx | 249 +++++ .../switchers-and-primitives.test.tsx | 291 ++++++ src/test/context/providers.test.tsx | 289 ++++++ src/test/fixtures.ts | 77 ++ src/test/hooks/hooks.test.tsx | 129 +++ src/test/lib/format.test.ts | 21 + src/test/lib/inject.test.ts | 64 ++ src/test/lib/logo-class.test.tsx | 39 + src/test/lib/nezha-api.test.ts | 107 ++ src/test/lib/static-data.test.ts | 25 + src/test/lib/utils.test.ts | 346 +++++++ src/test/pages/Server.test.tsx | 382 ++++++++ src/test/pages/pages.test.tsx | 130 +++ src/test/setup.ts | 110 +++ src/test/utils.tsx | 42 + tsconfig.app.json | 2 +- vite.config.ts | 35 +- 38 files changed, 5686 insertions(+), 23 deletions(-) create mode 100644 src/test/app.test.tsx create mode 100644 src/test/components/RemainPercentBar.test.tsx create mode 100644 src/test/components/ThemeProvider.test.tsx create mode 100644 src/test/components/basic-components.test.tsx create mode 100644 src/test/components/chart-primitives.test.tsx create mode 100644 src/test/components/dash-command.test.tsx create mode 100644 src/test/components/global-map.test.tsx create mode 100644 src/test/components/header.test.tsx create mode 100644 src/test/components/info-components.test.tsx create mode 100644 src/test/components/navigation-and-footer.test.tsx create mode 100644 src/test/components/network-chart.test.tsx create mode 100644 src/test/components/server-cards.test.tsx create mode 100644 src/test/components/server-detail-chart.test.tsx create mode 100644 src/test/components/server-detail-components.test.tsx create mode 100644 src/test/components/service-tracker.test.tsx create mode 100644 src/test/components/switchers-and-primitives.test.tsx create mode 100644 src/test/context/providers.test.tsx create mode 100644 src/test/fixtures.ts create mode 100644 src/test/hooks/hooks.test.tsx create mode 100644 src/test/lib/format.test.ts create mode 100644 src/test/lib/inject.test.ts create mode 100644 src/test/lib/logo-class.test.tsx create mode 100644 src/test/lib/nezha-api.test.ts create mode 100644 src/test/lib/static-data.test.ts create mode 100644 src/test/lib/utils.test.ts create mode 100644 src/test/pages/Server.test.tsx create mode 100644 src/test/pages/pages.test.tsx create mode 100644 src/test/setup.ts create mode 100644 src/test/utils.tsx diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7179d4a..386fa3a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,4 +14,12 @@ updates: groups: all: patterns: - - "*" \ No newline at end of file + - "*" + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + groups: + all: + patterns: + - "*" diff --git a/.github/workflows/auto-fix-lint-format-commit.yml b/.github/workflows/auto-fix-lint-format-commit.yml index d1658c0..cc702d7 100644 --- a/.github/workflows/auto-fix-lint-format-commit.yml +++ b/.github/workflows/auto-fix-lint-format-commit.yml @@ -1,11 +1,16 @@ name: Auto Fix Lint and Format on: - pull_request_target: + pull_request: types: [opened, synchronize] +permissions: + contents: write + pull-requests: write + jobs: auto-fix: + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - name: Checkout code @@ -13,29 +18,32 @@ jobs: with: fetch-depth: 0 ref: ${{ github.head_ref }} - repository: ${{ github.event.pull_request.head.repo.full_name }} - - name: Set up Bun - uses: oven-sh/setup-bun@v2 + - name: Set up pnpm + uses: pnpm/action-setup@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 with: - bun-version: "latest" + node-version: "22" + cache: "pnpm" - name: Install dependencies - run: bun install + run: pnpm install --frozen-lockfile - name: Run linter and fix issues - run: bun run lint:fix + run: pnpm run lint:fix - name: Run formatter - run: bun run format + run: pnpm run format - name: Check for changes id: check_changes run: | - git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV + git diff --exit-code || echo "has_changes=true" >> "$GITHUB_OUTPUT" - name: Commit and push changes - if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true' + if: steps.check_changes.outputs.has_changes == 'true' uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "chore: auto-fix linting and formatting issues" @@ -43,10 +51,10 @@ jobs: file_pattern: "." - name: Add PR comment - if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true' + if: steps.check_changes.outputs.has_changes == 'true' uses: actions/github-script@v9 with: - github-token: ${{secrets.GITHUB_TOKEN}} + github-token: ${{ secrets.GITHUB_TOKEN }} script: | github.rest.issues.createComment({ issue_number: context.issue.number, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 217dba5..8e8fd81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Run quality checks + run: pnpm run check + + - name: Run tests + run: pnpm run test:coverage + - name: Run build - run: pnpm run build \ No newline at end of file + run: pnpm run build diff --git a/.gitignore b/.gitignore index c234903..7b043b9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ package-lock.json dist dist-ssr dev-dist +coverage *.local # Editor directories and files diff --git a/package.json b/package.json index ac960c0..8b82095 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,23 @@ "private": true, "version": "2.0.2", "type": "module", + "engines": { + "node": ">=22", + "pnpm": ">=11" + }, "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "typecheck": "tsc -b", + "build": "pnpm run typecheck && vite build", "lint": "biome lint", "lint:fix": "biome lint --fix", "format": "biome format --write .", "check": "biome check", "check:fix": "biome check --fix --unsafe", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "verify": "pnpm run check && pnpm run test:coverage && pnpm run build", "preview": "vite preview" }, "dependencies": { @@ -56,15 +65,21 @@ "devDependencies": { "@biomejs/biome": "2.4.16", "@tailwindcss/postcss": "^4.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "25.9.2", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.8", "globals": "17.6.0", + "jsdom": "^29.1.1", "postcss": "8.5.15", "tailwindcss": "^4.3.0", "typescript": "~6.0.3", - "vite": "8.0.16" + "vite": "8.0.16", + "vitest": "^4.1.8" }, "packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0b37fa..1266251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,15 @@ importers: '@tailwindcss/postcss': specifier: ^4.3.0 version: 4.3.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: 25.9.2 version: 25.9.2 @@ -141,9 +150,15 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.2 version: 6.0.2(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)) + '@vitest/coverage-v8': + specifier: ^4.1.8 + version: 4.1.8(vitest@4.1.8) globals: specifier: 17.6.0 version: 17.6.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 postcss: specifier: 8.5.15 version: 8.5.15 @@ -156,17 +171,63 @@ importers: vite: specifier: 8.0.16 version: 8.0.16(@types/node@25.9.2)(jiti@2.7.0) + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.4.16': resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==} engines: {node: '>=14.21.3'} @@ -224,6 +285,46 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.4': + resolution: {integrity: sha512-yI8kNhHiOrLb8Rlulsk07DeQz0PwyT69FX9dkz5rAp7p9RUwFKEXnZpBGzURiLHgi66YqIWxOHn1nij8Lrg27Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -233,6 +334,15 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + 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 + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -958,9 +1068,44 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -991,6 +1136,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -1024,10 +1175,77 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + peerDependencies: + '@vitest/browser': 4.1.8 + vitest: 4.1.8 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1041,6 +1259,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1048,6 +1269,13 @@ packages: country-flag-icons@1.6.17: resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1099,12 +1327,23 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1115,16 +1354,36 @@ packages: diacritics@1.3.0: resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + enhanced-resolve@5.23.0: resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-toolkit@1.47.0: resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1150,6 +1409,17 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -1171,14 +1441,48 @@ packages: immer@11.1.8: resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1253,6 +1557,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lucide-react@1.17.0: resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} peerDependencies: @@ -1262,14 +1570,42 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1281,6 +1617,14 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -1302,6 +1646,9 @@ packages: typescript: optional: true + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -1376,6 +1723,10 @@ packages: react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -1384,6 +1735,10 @@ packages: redux@5.0.1: resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -1392,12 +1747,24 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -1408,6 +1775,23 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -1426,10 +1810,36 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.2: + resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} + + tldts@7.4.2: + resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1441,6 +1851,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.27.2: + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + engines: {node: '>=20.18.1'} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -1512,16 +1926,128 @@ packages: yaml: optional: true + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.4(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.4.16': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.16 @@ -1557,6 +2083,34 @@ snapshots: '@biomejs/cli-win32-x64@2.4.16': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.4(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1573,6 +2127,8 @@ snapshots: tslib: 2.8.1 optional: true + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -2233,11 +2789,52 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -2266,6 +2863,10 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} '@types/luxon@3.7.1': {} @@ -2289,10 +2890,89 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0) + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)) + + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + chai@6.2.2: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -2311,10 +2991,19 @@ snapshots: - '@types/react' - '@types/react-dom' + convert-source-map@2.0.0: {} + cookie@1.1.1: {} country-flag-icons@1.6.17: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} d3-array@3.2.4: @@ -2359,25 +3048,50 @@ snapshots: d3-timer@3.0.1: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dayjs@1.11.21: {} decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} diacritics@1.3.0: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + enhanced-resolve@5.23.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + + es-module-lexer@2.1.0: {} + es-toolkit@1.47.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2391,6 +3105,16 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -2407,10 +3131,57 @@ snapshots: immer@11.1.8: {} + indent-string@4.0.0: {} + internmap@2.0.3: {} + is-potential-custom-element-name@1.0.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.27.2 + 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 + transitivePeerDependencies: + - '@noble/hashes' + lightningcss-android-arm64@1.32.0: optional: true @@ -2460,18 +3231,44 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lru-cache@11.5.1: {} + lucide-react@1.17.0(react@19.2.7): dependencies: react: 19.2.7 luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.4 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + nanoid@3.3.12: {} + obug@2.1.3: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -2482,6 +3279,14 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -2498,6 +3303,8 @@ snapshots: react-dom: 19.2.7(react@19.2.7) typescript: 6.0.3 + react-is@17.0.2: {} + react-is@18.3.1: {} react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1): @@ -2572,12 +3379,19 @@ snapshots: - '@types/react' - redux + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 redux@5.0.1: {} + require-from-string@2.0.2: {} + reselect@5.1.1: {} rolldown@1.0.3: @@ -2601,10 +3415,18 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} + semver@7.8.4: {} + set-cookie-parser@2.7.2: {} + siginfo@2.0.0: {} + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 @@ -2612,6 +3434,20 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + tailwind-merge@3.6.0: {} tailwindcss-animate@1.0.7(tailwindcss@4.3.0): @@ -2624,17 +3460,39 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + + tldts-core@7.4.2: {} + + tldts@7.4.2: + dependencies: + tldts-core: 7.4.2 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.2 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tslib@2.8.1: {} typescript@6.0.3: {} undici-types@7.24.6: {} + undici@7.27.2: {} + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 @@ -2683,4 +3541,58 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 + vitest@4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.2 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/src/pages/ServerDetail.tsx b/src/pages/ServerDetail.tsx index 959b55e..eb1c238 100644 --- a/src/pages/ServerDetail.tsx +++ b/src/pages/ServerDetail.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { Navigate, useParams } from "react-router-dom"; import { NetworkChart } from "@/components/NetworkChart"; import ServerDetailChart from "@/components/ServerDetailChart"; import ServerDetailOverview from "@/components/ServerDetailOverview"; @@ -7,8 +7,6 @@ import TabSwitch from "@/components/TabSwitch"; import { Separator } from "@/components/ui/separator"; export default function ServerDetail() { - const navigate = useNavigate(); - useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: "instant" }); }, []); @@ -19,8 +17,7 @@ export default function ServerDetail() { const { id: server_id } = useParams(); if (!server_id) { - navigate("/404"); - return null; + return ; } return ( diff --git a/src/test/app.test.tsx b/src/test/app.test.tsx new file mode 100644 index 0000000..c036a2e --- /dev/null +++ b/src/test/app.test.tsx @@ -0,0 +1,141 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import App from "@/App"; +import { createTestQueryClient } from "@/test/utils"; + +const appMocks = vi.hoisted(() => ({ + backgroundImage: undefined as string | undefined, + fetchSetting: vi.fn(), + injectContext: vi.fn(), + setTheme: vi.fn(), +})); + +vi.mock("../components/DashCommand", () => ({ + DashCommand: () =>
dash-command
, +})); + +vi.mock("../components/Footer", () => ({ + default: () => , +})); + +vi.mock("../components/Header", () => ({ + default: () =>
header
, + RefreshToast: () =>
refresh-toast
, +})); + +vi.mock("../pages/Server", () => ({ + default: () =>
server-page
, +})); + +vi.mock("../pages/ServerDetail", () => ({ + default: () =>
server-detail-page
, +})); + +vi.mock("../hooks/use-background", () => ({ + useBackground: () => ({ backgroundImage: appMocks.backgroundImage }), +})); + +vi.mock("../hooks/use-theme", () => ({ + useTheme: () => ({ setTheme: appMocks.setTheme }), +})); + +vi.mock("../lib/inject", () => ({ + InjectContext: appMocks.injectContext, +})); + +vi.mock("../lib/nezha-api", () => ({ + fetchSetting: appMocks.fetchSetting, +})); + +function settingResponse(customCode = "") { + return { + success: true, + data: { + config: { + debug: false, + language: "zh-CN", + site_name: "Nezha", + user_template: "", + admin_template: "", + custom_code: customCode, + }, + version: "1.0.0", + }, + }; +} + +function renderApp(route = "/") { + window.history.pushState({}, "", route); + const queryClient = createTestQueryClient(); + + return render( + + + , + ); +} + +describe("App", () => { + beforeEach(() => { + appMocks.backgroundImage = undefined; + appMocks.fetchSetting.mockResolvedValue(settingResponse()); + appMocks.injectContext.mockResolvedValue(undefined); + }); + + it("renders the main shell after settings load and applies global theme/background settings", async () => { + Object.assign(window, { + ForceTheme: "dark", + CustomMobileBackgroundImage: "/mobile.png", + }); + appMocks.backgroundImage = "/desktop.png"; + + const { container } = renderApp(); + + expect(await screen.findByText("server-page")).toBeInTheDocument(); + expect(screen.getByText("refresh-toast")).toBeInTheDocument(); + expect(screen.getByText("header")).toBeInTheDocument(); + expect(screen.getByText("dash-command")).toBeInTheDocument(); + expect(screen.getByText("footer")).toBeInTheDocument(); + expect(appMocks.setTheme).toHaveBeenCalledWith("dark"); + expect( + Array.from(container.querySelectorAll("[style]")).some( + (element) => element.style.backgroundImage.includes("/desktop.png"), + ), + ).toBe(true); + expect( + Array.from(container.querySelectorAll("[style]")).some( + (element) => element.style.backgroundImage.includes("/mobile.png"), + ), + ).toBe(true); + }); + + it("injects custom code before showing the app shell", async () => { + appMocks.fetchSetting.mockResolvedValue( + settingResponse(""), + ); + + renderApp(); + + await waitFor(() => { + expect(appMocks.injectContext).toHaveBeenCalledWith( + "", + ); + }); + expect(await screen.findByText("server-page")).toBeInTheDocument(); + }); + + it("renders fetch errors through the error page", async () => { + appMocks.fetchSetting.mockRejectedValue(new Error("settings failed")); + + renderApp(); + + expect(await screen.findByText("settings failed")).toBeInTheDocument(); + }); + + it("routes server detail paths through the app router", async () => { + renderApp("/server/42"); + + expect(await screen.findByText("server-detail-page")).toBeInTheDocument(); + }); +}); diff --git a/src/test/components/RemainPercentBar.test.tsx b/src/test/components/RemainPercentBar.test.tsx new file mode 100644 index 0000000..3c04610 --- /dev/null +++ b/src/test/components/RemainPercentBar.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import RemainPercentBar from "@/components/RemainPercentBar"; + +describe("RemainPercentBar", () => { + it("uses status colors for low, medium, and healthy remaining percentages", () => { + const { rerender } = render(); + const progress = screen.getByRole("progressbar"); + const indicator = progress.firstElementChild; + + expect(progress).toHaveClass("w-[70px]"); + expect(indicator).toHaveClass("bg-red-500"); + expect(indicator).toHaveStyle({ transform: "translateX(-71%)" }); + + rerender(); + expect(progress.firstElementChild).toHaveClass("bg-orange-400"); + + rerender(); + expect(progress.firstElementChild).toHaveClass("bg-green-500"); + }); +}); diff --git a/src/test/components/ThemeProvider.test.tsx b/src/test/components/ThemeProvider.test.tsx new file mode 100644 index 0000000..3fbb659 --- /dev/null +++ b/src/test/components/ThemeProvider.test.tsx @@ -0,0 +1,144 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ThemeColorManager } from "@/components/ThemeColorManager"; +import { + ThemeProvider, + ThemeProviderContext, +} from "@/components/ThemeProvider"; +import { useTheme } from "@/hooks/use-theme"; + +function ThemeProbe() { + const { setTheme, theme } = useTheme(); + + return ( +
+

{theme}

+ + +
+ ); +} + +function InvalidThemeProbe() { + useTheme(); + return null; +} + +describe("ThemeProvider", () => { + it("applies theme classes, meta color, and localStorage state", async () => { + const user = userEvent.setup(); + const meta = document.createElement("meta"); + meta.name = "theme-color"; + document.head.appendChild(meta); + + render( + + + , + ); + + expect(screen.getByTestId("theme-state")).toHaveTextContent("system"); + expect(document.documentElement).toHaveClass("light"); + + await user.click(screen.getByRole("button", { name: "dark" })); + + expect(screen.getByTestId("theme-state")).toHaveTextContent("dark"); + expect(localStorage.getItem("theme-test")).toBe("dark"); + expect(document.documentElement).toHaveClass("dark"); + expect(document.documentElement.style.colorScheme).toBe("dark"); + expect(document.querySelector('meta[name="theme-color"]')).toHaveAttribute( + "content", + "hsl(30 15% 8%)", + ); + + await user.click(screen.getByRole("button", { name: "light" })); + expect(document.documentElement).toHaveClass("light"); + }); + + it("uses stored theme value as the initial theme", () => { + localStorage.setItem("theme-test", "dark"); + + render( + + + , + ); + + expect(screen.getByTestId("theme-state")).toHaveTextContent("dark"); + expect(document.documentElement).toHaveClass("dark"); + }); + + it("follows system color scheme changes and cleans up its listener", () => { + let systemDark = true; + let changeListener: (() => void) | undefined; + const addEventListener = vi.fn((_event: string, listener: () => void) => { + changeListener = listener; + }); + const removeEventListener = vi.fn(); + + vi.mocked(window.matchMedia).mockReturnValue({ + get matches() { + return systemDark; + }, + media: "(prefers-color-scheme: dark)", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener, + removeEventListener, + dispatchEvent: vi.fn(), + } as unknown as MediaQueryList); + + const { unmount } = render( + + + , + ); + + expect(document.documentElement).toHaveClass("dark"); + + act(() => { + systemDark = false; + changeListener?.(); + }); + + expect(document.documentElement).toHaveClass("light"); + + unmount(); + + expect(removeEventListener).toHaveBeenCalledWith("change", changeListener); + }); + + it("keeps theme-color meta in sync through ThemeColorManager", () => { + render( + + + + , + ); + + act(() => { + screen.getByRole("button", { name: "dark" }).click(); + }); + + expect(document.querySelector('meta[name="theme-color"]')).toHaveAttribute( + "content", + "hsl(30 15% 8%)", + ); + }); + + it("throws when the theme context is explicitly unavailable", () => { + expect(() => + render( + + + , + ), + ).toThrow("useTheme must be used within a ThemeProvider"); + }); +}); diff --git a/src/test/components/basic-components.test.tsx b/src/test/components/basic-components.test.tsx new file mode 100644 index 0000000..6d16986 --- /dev/null +++ b/src/test/components/basic-components.test.tsx @@ -0,0 +1,190 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import AnimateCountClient, { AnimateCount } from "@/components/AnimatedCount"; +import ErrorBoundary from "@/components/ErrorBoundary"; +import ChartSkeleton from "@/components/loading/ChartSkeleton"; +import { Loader, LoadingSpinner } from "@/components/loading/Loader"; +import NetworkChartLoading from "@/components/NetworkChartLoading"; +import { SearchButton } from "@/components/SearchButton"; +import ServerOverview from "@/components/ServerOverview"; +import ServerUsageBar from "@/components/ServerUsageBar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; + +vi.mock("@numeric-text/react", () => ({ + default: ({ + className, + value, + }: { + className?: string; + value: string | number; + }) => {value}, +})); + +import { CommandProvider } from "@/context/command-provider"; +import { StatusProvider } from "@/context/status-provider"; +import { useCommand } from "@/hooks/use-command"; +import { useStatus } from "@/hooks/use-status"; + +function BrokenComponent(): never { + throw new Error("render failed"); +} + +function CommandReadout() { + const { isOpen } = useCommand(); + return

{isOpen ? "command-open" : "command-closed"}

; +} + +function StatusReadout() { + const { status } = useStatus(); + return

{`status:${status}`}

; +} + +describe("basic display components", () => { + it("renders animated counts with padded digits", () => { + render({7}); + + expect(screen.getAllByText("0")).toHaveLength(4); + expect(screen.getAllByText("7")).toHaveLength(1); + }); + + it("keeps the animated count client stable across count changes", () => { + const { rerender } = render( + , + ); + + expect(screen.getAllByText("1")).toHaveLength(2); + rerender(); + expect(screen.getByText("2")).toBeInTheDocument(); + }); + + it("renders server usage colors for healthy, warning, and critical values", () => { + const { rerender } = render(); + expect(screen.getByRole("progressbar").firstElementChild).toHaveClass( + "bg-green-500", + ); + + rerender(); + expect(screen.getByRole("progressbar").firstElementChild).toHaveClass( + "bg-orange-400", + ); + + rerender(); + expect(screen.getByRole("progressbar").firstElementChild).toHaveClass( + "bg-red-500", + ); + }); + + it("renders loading primitives", () => { + const { container } = render( + <> + + + + + , + ); + + expect( + container.querySelector("[data-visible='true']"), + ).toBeInTheDocument(); + expect(container.querySelectorAll(".hamster-loading-bar")).toHaveLength(16); + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); + expect(container.querySelector(".h-\\[250px\\]")).toBeInTheDocument(); + }); + + it("catches render errors with ErrorBoundary", () => { + render( + + + , + ); + + expect(screen.getByText("500")).toBeInTheDocument(); + expect(screen.getByText("render failed")).toBeInTheDocument(); + }); +}); + +describe("interactive app controls", () => { + it("opens command search from the search button", async () => { + const user = userEvent.setup(); + render( + + + + , + ); + + expect(screen.getByText("command-closed")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Search" })); + expect(screen.getByText("command-open")).toBeInTheDocument(); + }); + + it("lets overview cards update the global status filter", () => { + render( + + + + , + ); + + expect(screen.getByText("status:all")).toBeInTheDocument(); + fireEvent.click(screen.getByText("serverOverview.onlineServers")); + expect(screen.getByText("status:online")).toBeInTheDocument(); + fireEvent.click(screen.getByText("serverOverview.offlineServers")); + expect(screen.getByText("status:offline")).toBeInTheDocument(); + fireEvent.click(screen.getByText("serverOverview.totalServers")); + expect(screen.getByText("status:all")).toBeInTheDocument(); + }); +}); + +describe("ui primitives", () => { + it("forwards classes and events through Button and Input", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const onChange = vi.fn(); + render( + <> + + + , + ); + + await user.click(screen.getByRole("button", { name: "Save" })); + await user.type(screen.getByPlaceholderText("Name"), "edge"); + + expect(onClick).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Save" })).toHaveClass( + "bg-secondary", + ); + }); + + it("renders skeletons and separators with expected orientation classes", () => { + const { container } = render( + <> + + + + , + ); + + expect(screen.getByTestId("skeleton")).toHaveClass("animate-pulse", "h-4"); + expect(screen.getByTestId("horizontal")).toHaveClass("h-px", "w-full"); + expect(screen.getByTestId("vertical")).toHaveClass("h-full", "w-px"); + expect(container.querySelectorAll("[data-orientation]")).toHaveLength(2); + }); +}); diff --git a/src/test/components/chart-primitives.test.tsx b/src/test/components/chart-primitives.test.tsx new file mode 100644 index 0000000..f7c85db --- /dev/null +++ b/src/test/components/chart-primitives.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + ChartContainer, + ChartLegendContent, + ChartTooltipContent, +} from "@/components/ui/chart"; + +function CpuIcon() { + return icon; +} + +const chartConfig = { + cpu: { + label: "CPU Usage", + icon: CpuIcon, + }, + mem: { + label: "Memory", + color: "#00ff00", + }, + themed: { + label: "Themed", + theme: { + light: "#ffffff", + dark: "#000000", + }, + }, +}; + +describe("chart primitives", () => { + it("renders tooltip rows with config labels, icons, and formatted values", () => { + const formatter = vi.fn((value: number, name: string) => ( + {`${name}:${value.toFixed(1)}`} + )); + + render( + + + , + ); + + expect(screen.getByText("CPU Usage")).toBeInTheDocument(); + expect(screen.getByText("cpu:12.3")).toBeInTheDocument(); + expect(screen.getByText("mem:42.0")).toBeInTheDocument(); + expect(formatter).toHaveBeenCalled(); + }); + + it("renders nested labels for single line indicators and hides inactive tooltips", () => { + const { container, rerender } = render( + + + , + ); + + expect(screen.getAllByText("CPU Usage")).toHaveLength(2); + expect(screen.getByText("12.00")).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(container).not.toHaveTextContent("CPU Usage"); + }); + + it("renders legend payloads and chart theme style blocks", () => { + const { container, rerender } = render( + + + , + ); + + expect(screen.getByTestId("cpu-icon")).toBeInTheDocument(); + expect(screen.getByText("mem")).toBeInTheDocument(); + expect(container.querySelector("style")?.textContent).toContain( + "--color-themed", + ); + + rerender( + + + , + ); + + expect(screen.queryByText("mem")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/components/dash-command.test.tsx b/src/test/components/dash-command.test.tsx new file mode 100644 index 0000000..14d89e7 --- /dev/null +++ b/src/test/components/dash-command.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DashCommand } from "@/components/DashCommand"; +import { createServer } from "@/test/fixtures"; + +const dashMocks = vi.hoisted(() => ({ + closeCommand: vi.fn(), + isOpen: true, + lastMessage: null as MessageEvent | null, + navigate: vi.fn(), + setTheme: vi.fn(), + toggleCommand: vi.fn(), + connected: true, +})); + +vi.mock("@/hooks/use-command", () => ({ + useCommand: () => ({ + closeCommand: dashMocks.closeCommand, + isOpen: dashMocks.isOpen, + toggleCommand: dashMocks.toggleCommand, + }), +})); + +vi.mock("@/hooks/use-theme", () => ({ + useTheme: () => ({ + setTheme: dashMocks.setTheme, + }), +})); + +vi.mock("@/hooks/use-websocket-context", () => ({ + useWebSocketContext: () => ({ + connected: dashMocks.connected, + lastMessage: dashMocks.lastMessage, + }), +})); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => dashMocks.navigate, + }; +}); + +function seedWebSocketData() { + dashMocks.connected = true; + dashMocks.lastMessage = new MessageEvent("message", { + data: JSON.stringify({ + now: Date.parse("2025-01-01T00:00:00.000Z") / 1000, + servers: [ + createServer({ + id: 7, + name: "edge-7", + last_active: "2025-01-01T00:00:00.000Z", + }), + ], + }), + }); +} + +describe("DashCommand", () => { + it("does not render until websocket data is available", () => { + dashMocks.connected = false; + dashMocks.lastMessage = null; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders server and shortcut commands, handles selection, and listens for keyboard toggle", async () => { + const user = userEvent.setup(); + seedWebSocketData(); + + render(); + + fireEvent.keyDown(document, { key: "k", metaKey: true }); + expect(dashMocks.toggleCommand).toHaveBeenCalledOnce(); + + expect(screen.getByPlaceholderText("TypeCommand")).toBeInTheDocument(); + expect(screen.getByText("edge-7")).toBeInTheDocument(); + expect(screen.getByText("ToggleDarkMode")).toBeInTheDocument(); + + await user.click(screen.getByText("edge-7")); + expect(dashMocks.navigate).toHaveBeenCalledWith("/server/7"); + expect(dashMocks.closeCommand).toHaveBeenCalled(); + + await user.click(screen.getByText("ToggleDarkMode")); + expect(dashMocks.setTheme).toHaveBeenCalledWith("dark"); + }); +}); diff --git a/src/test/components/global-map.test.tsx b/src/test/components/global-map.test.tsx new file mode 100644 index 0000000..a5b8fdd --- /dev/null +++ b/src/test/components/global-map.test.tsx @@ -0,0 +1,175 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import GlobalMap, { InteractiveMap } from "@/components/GlobalMap"; +import { TooltipProvider } from "@/context/tooltip-provider"; +import { createServer } from "@/test/fixtures"; + +type InteractiveMapProps = Parameters[0]; + +const now = Date.parse("2025-01-01T00:00:20.000Z"); + +function LocationProbe() { + const location = useLocation(); + return

{location.pathname}

; +} + +function renderMap(ui: React.ReactElement) { + return render( + + + {ui} + + + , + ); +} + +function mapFeature(code: string, name: string) { + return { + type: "Feature", + properties: { + iso_a2_eh: code, + iso_a3_eh: `${code}A`, + name, + }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-100, 30], + [-90, 30], + [-90, 40], + [-100, 40], + [-100, 30], + ], + ], + }, + }; +} + +describe("GlobalMap", () => { + it("counts unique countries and ignores servers without country codes", () => { + Object.assign(window, { CustomBackgroundImage: "/background.png" }); + + const { container } = renderMap( + , + ); + + expect( + screen.getByText(/map\.Distributions 2 map\.Regions/), + ).toBeInTheDocument(); + expect(container.querySelector("section")).toHaveClass("bg-card/70"); + }); +}); + +describe("InteractiveMap", () => { + it("shows country tooltips, navigates from servers, and clears tooltip state", async () => { + const user = userEvent.setup(); + const filteredFeatures = [ + mapFeature("US", "United States"), + mapFeature("DE", "Germany"), + ] as unknown as InteractiveMapProps["filteredFeatures"]; + + const { container } = renderMap( + , + ); + + const highlightedCountry = container.querySelector("path.fill-green-700"); + expect(highlightedCountry).toBeInTheDocument(); + + fireEvent.mouseEnter(highlightedCountry as SVGPathElement); + + expect(await screen.findByText("United States")).toBeInTheDocument(); + expect(screen.getByText("2 map.Servers")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /us-offline/ })); + + expect(sessionStorage.getItem("fromMainPage")).toBe("true"); + expect(screen.getByText("/server/12")).toBeInTheDocument(); + + const fallbackMarker = container.querySelector("circle.fill-sky-700"); + expect(fallbackMarker).toBeInTheDocument(); + + fireEvent.mouseEnter(fallbackMarker as SVGCircleElement); + + expect(await screen.findByText("Singapore")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /sg-edge/ })).toBeInTheDocument(); + + fireEvent.mouseLeave(container.querySelector(".relative") as HTMLElement); + + expect(screen.queryByText("Singapore")).not.toBeInTheDocument(); + }); + + it("clears tooltip state for non-highlighted countries and empty map areas", () => { + const filteredFeatures = [ + mapFeature("US", "United States"), + mapFeature("DE", "Germany"), + ] as unknown as InteractiveMapProps["filteredFeatures"]; + + const { container } = renderMap( + , + ); + + const [highlightedCountry, neutralCountry] = Array.from( + container.querySelectorAll("path"), + ); + fireEvent.mouseEnter(highlightedCountry as SVGPathElement); + expect(screen.getByText("United States")).toBeInTheDocument(); + + fireEvent.mouseEnter(neutralCountry as SVGPathElement); + expect(screen.queryByText("United States")).not.toBeInTheDocument(); + + fireEvent.mouseEnter(container.querySelector("rect") as SVGRectElement); + expect(screen.queryByText("United States")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/components/header.test.tsx b/src/test/components/header.test.tsx new file mode 100644 index 0000000..435c45e --- /dev/null +++ b/src/test/components/header.test.tsx @@ -0,0 +1,243 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactElement } from "react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Header, { RefreshToast } from "@/components/Header"; +import { ThemeProvider } from "@/components/ThemeProvider"; +import { CommandProvider } from "@/context/command-provider"; +import { createSettingResponse } from "@/test/fixtures"; +import { createTestQueryClient } from "@/test/utils"; + +const headerMocks = vi.hoisted(() => ({ + backgroundImage: undefined as string | undefined, + connected: true, + fetchLoginUser: vi.fn(), + fetchSetting: vi.fn(), + lastMessage: null as MessageEvent | null, + needReconnect: false, + setNeedReconnect: vi.fn(), + updateBackground: vi.fn(), +})); + +vi.mock("@numeric-text/react", () => ({ + default: ({ value }: { value: string | number }) => ( + {value} + ), +})); + +vi.mock("@/hooks/use-background", () => ({ + useBackground: () => ({ + backgroundImage: headerMocks.backgroundImage, + updateBackground: headerMocks.updateBackground, + }), +})); + +vi.mock("@/hooks/use-websocket-context", () => ({ + useWebSocketContext: () => ({ + connected: headerMocks.connected, + lastMessage: headerMocks.lastMessage, + needReconnect: headerMocks.needReconnect, + setNeedReconnect: headerMocks.setNeedReconnect, + }), +})); + +vi.mock("@/lib/nezha-api", () => ({ + fetchLoginUser: headerMocks.fetchLoginUser, + fetchSetting: headerMocks.fetchSetting, +})); + +function settingResponse(siteName = "Nezha") { + return { + ...createSettingResponse(), + data: { + ...createSettingResponse().data, + config: { + ...createSettingResponse().data.config, + site_name: siteName, + }, + }, + }; +} + +function loginResponse() { + return { + success: true, + data: { + id: 1, + username: "admin", + password: "", + created_at: "2025-01-01T00:00:00.000Z", + updated_at: "2025-01-01T00:00:00.000Z", + }, + }; +} + +function LocationProbe() { + const location = useLocation(); + return

{location.pathname}

; +} + +function renderHeader(route = "/server/1") { + return render( + + + + +
+ + + + + , + ); +} + +function renderInRouter(ui: ReactElement) { + return render({ui}); +} + +describe("Header", () => { + beforeEach(() => { + headerMocks.backgroundImage = undefined; + headerMocks.connected = true; + headerMocks.fetchLoginUser.mockReset(); + headerMocks.fetchSetting.mockReset(); + headerMocks.lastMessage = new MessageEvent("message", { + data: JSON.stringify({ online: 4 }), + }); + headerMocks.needReconnect = false; + headerMocks.setNeedReconnect.mockReset(); + headerMocks.updateBackground.mockReset(); + Object.assign(window, { + CustomBackgroundImage: "", + CustomDesc: "", + CustomLinks: "", + CustomLogo: "", + CustomMobileBackgroundImage: "", + }); + headerMocks.fetchSetting.mockResolvedValue(settingResponse()); + headerMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous")); + }); + + it("renders configured site identity, custom links, online count, and dashboard state", async () => { + const user = userEvent.setup(); + Object.assign(window, { + CustomDesc: "edge status", + CustomLinks: JSON.stringify([ + { link: "https://example.test", name: "Docs" }, + ]), + CustomLogo: "/logo.png", + }); + Object.defineProperty(document, "cookie", { + configurable: true, + value: "session=1", + }); + sessionStorage.setItem("selectedGroup", "Edge"); + headerMocks.fetchSetting.mockResolvedValue(settingResponse("Status Hub")); + headerMocks.fetchLoginUser.mockResolvedValue(loginResponse()); + + renderHeader(); + + const siteName = await screen.findByText("Status Hub"); + expect(screen.getByText("edge status")).toBeInTheDocument(); + expect(screen.getByAltText("apple-touch-icon")).toHaveAttribute( + "src", + "/logo.png", + ); + expect(screen.getAllByRole("link", { name: "Docs" })).toHaveLength(2); + expect(await screen.findAllByText("dashboard")).toHaveLength(2); + expect(screen.getByTestId("numeric-text")).toHaveTextContent("4"); + expect(screen.getByText("online")).toBeInTheDocument(); + + await waitFor(() => { + expect(document.title).toBe("Status Hub"); + }); + expect(document.querySelector("link[rel='shortcut icon']")).toHaveAttribute( + "href", + "/logo.png", + ); + + await user.click(siteName); + + expect(sessionStorage.getItem("selectedGroup")).toBeNull(); + expect(screen.getByText("/")).toBeInTheDocument(); + }); + + it("uses the offline display and login links when websocket and auth are unavailable", async () => { + headerMocks.connected = false; + + const { container } = renderHeader(); + + expect(await screen.findAllByText("login")).toHaveLength(2); + expect(screen.getByText("offline")).toBeInTheDocument(); + expect( + container.querySelector("[data-visible='true']"), + ).toBeInTheDocument(); + }); + + it("stores and removes the active custom background", async () => { + const user = userEvent.setup(); + headerMocks.backgroundImage = "/desktop.png"; + Object.assign(window, { + CustomBackgroundImage: "/desktop.png", + CustomMobileBackgroundImage: "/mobile.png", + }); + + const { container } = renderHeader(); + await screen.findByText("Nezha"); + + const toggleButton = container + .querySelector(".lucide-image-minus") + ?.closest("button"); + expect(toggleButton).toBeInTheDocument(); + + await user.click(toggleButton as HTMLButtonElement); + + expect(sessionStorage.getItem("savedBackgroundImage")).toBe("/desktop.png"); + expect(headerMocks.updateBackground).toHaveBeenCalledWith(undefined); + }); + + it("restores the saved custom background", async () => { + const user = userEvent.setup(); + sessionStorage.setItem("savedBackgroundImage", "/saved.png"); + + const { container } = renderHeader(); + await screen.findByText("Nezha"); + + const toggleButton = container + .querySelector(".lucide-image-minus") + ?.closest("button"); + expect(toggleButton).toBeInTheDocument(); + + await user.click(toggleButton as HTMLButtonElement); + + expect(headerMocks.updateBackground).toHaveBeenCalledWith("/saved.png"); + }); +}); + +describe("RefreshToast", () => { + beforeEach(() => { + headerMocks.needReconnect = false; + }); + + it("renders only while reconnect refresh is needed", () => { + vi.useFakeTimers(); + sessionStorage.setItem("needRefresh", "true"); + + const { container, rerender } = renderInRouter(); + + expect(container).toBeEmptyDOMElement(); + + headerMocks.needReconnect = true; + rerender( + + + , + ); + + expect(screen.getByText("refreshing...")).toBeInTheDocument(); + expect(sessionStorage.getItem("needRefresh")).toBeNull(); + }); +}); diff --git a/src/test/components/info-components.test.tsx b/src/test/components/info-components.test.tsx new file mode 100644 index 0000000..11036c6 --- /dev/null +++ b/src/test/components/info-components.test.tsx @@ -0,0 +1,218 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useEffect } from "react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import BillingInfo from "@/components/billingInfo"; +import MapTooltip from "@/components/MapTooltip"; +import PlanInfo from "@/components/PlanInfo"; +import ServerFlag from "@/components/ServerFlag"; +import { TooltipProvider } from "@/context/tooltip-provider"; +import { useTooltip } from "@/hooks/use-tooltip"; +import type { PublicNoteData } from "@/lib/utils"; + +const planData: PublicNoteData = { + planDataMod: { + bandwidth: "1Gbps", + trafficVol: "2TB", + trafficType: "monthly", + IPv4: "1", + IPv6: "1", + networkRoute: "CN2,CMI", + extra: "Premium,Backup", + }, +}; + +function TooltipSeeder() { + const { setTooltipData } = useTooltip(); + + useEffect(() => { + setTooltipData({ + centroid: [12, 34], + country: "China", + count: 2, + servers: [ + { id: 1, name: "edge-1", status: true }, + { id: 2, name: "edge-2", status: false }, + ], + }); + }, [setTooltipData]); + + return null; +} + +function LocationProbe() { + const location = useLocation(); + return

{location.pathname}

; +} + +describe("PlanInfo and BillingInfo", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z")); + }); + + it("renders plan badges for every supported public note field", () => { + render(); + + expect(screen.getByText("1Gbps")).toBeInTheDocument(); + expect(screen.getByText("2TB")).toBeInTheDocument(); + expect(screen.getByText("IPv4")).toBeInTheDocument(); + expect(screen.getByText("IPv6")).toBeInTheDocument(); + expect(screen.getByText("CN2|CMI")).toBeInTheDocument(); + expect(screen.getByText("Premium")).toBeInTheDocument(); + expect(screen.getByText("Backup")).toBeInTheDocument(); + }); + + it("renders active, free, usage-based, indefinite, and expired billing states", () => { + const { rerender } = render( + , + ); + + expect( + screen.getByText("billingInfo.price: 10/monthly"), + ).toBeInTheDocument(); + expect(screen.getByText(/billingInfo.remaining: 16/)).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("billingInfo.free")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("billingInfo.usage-baseed")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText(/billingInfo.indefinite/)).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText(/billingInfo.expired/)).toBeInTheDocument(); + }); +}); + +describe("MapTooltip", () => { + it("renders tooltip data and navigates to selected servers", async () => { + const user = userEvent.setup(); + render( + + + + + + + , + ); + + expect(await screen.findByText("Mainland China")).toBeInTheDocument(); + expect(screen.getByText("2 map.Servers")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /edge-2/ })); + + expect(sessionStorage.getItem("fromMainPage")).toBe("true"); + expect(screen.getByText("/server/2")).toBeInTheDocument(); + }); +}); + +describe("ServerFlag", () => { + it("uses SVG flag classes when emoji flags are forced off", () => { + Object.assign(window, { ForceUseSvgFlag: true }); + const { container } = render(); + + expect(container.querySelector(".fi-us")).toBeInTheDocument(); + }); + + it("uses emoji flags when the canvas probe detects support", async () => { + Object.assign(window, { ForceUseSvgFlag: false }); + const originalCreateElement = document.createElement.bind(document); + const canvasContext = { + fillStyle: "", + textBaseline: "", + font: "", + fillText: vi.fn(), + getImageData: vi.fn(() => ({ + data: new Uint8ClampedArray([0, 0, 0, 255]), + })), + } as unknown as CanvasRenderingContext2D; + vi.spyOn(document, "createElement").mockImplementation( + (tagName, options) => { + if (tagName === "canvas") { + return { + getContext: vi.fn(() => canvasContext), + } as unknown as HTMLCanvasElement; + } + + return originalCreateElement(tagName, options); + }, + ); + + const { container } = render(); + + await waitFor(() => { + expect(container).toHaveTextContent("🇺🇸"); + }); + expect(container.querySelector(".fi-US")).not.toBeInTheDocument(); + }); + + it("renders nothing for missing country codes", () => { + Object.assign(window, { ForceUseSvgFlag: true }); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/test/components/navigation-and-footer.test.tsx b/src/test/components/navigation-and-footer.test.tsx new file mode 100644 index 0000000..ef18832 --- /dev/null +++ b/src/test/components/navigation-and-footer.test.tsx @@ -0,0 +1,158 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Footer from "@/components/Footer"; +import GroupSwitch from "@/components/GroupSwitch"; +import { + ServerDetailChartLoading, + ServerDetailLoading, +} from "@/components/loading/ServerDetailLoading"; +import TabSwitch from "@/components/TabSwitch"; +import { createTestQueryClient, renderWithProviders } from "@/test/utils"; + +const apiMocks = vi.hoisted(() => ({ + fetchSetting: vi.fn(), +})); + +vi.mock("@/lib/nezha-api", () => apiMocks); + +function LocationProbe() { + const location = useLocation(); + return

{location.pathname}

; +} + +describe("Footer", () => { + beforeEach(() => { + apiMocks.fetchSetting.mockResolvedValue({ + success: true, + data: { + config: { + debug: false, + language: "en-US", + site_name: "Nezha", + user_template: "", + admin_template: "", + custom_code: "", + }, + version: "9.9.9", + }, + }); + }); + + it("renders setting version and project attribution", async () => { + renderWithProviders(