From 527c860bf81dbcca7b4dd8bbf6be37f28d60e9e6 Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 20:06:52 +0100 Subject: [PATCH] =?UTF-8?q?feat(web):=20frontend=20CI,=20vitest=20suite,?= =?UTF-8?q?=20navbar=E2=86=92React=20SPA=20shell=20=E2=80=94=20Phase=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend CI (closes the no-coverage gap for the TSX): - ci.yml: new 'frontend' job — npm ci, tsc --noEmit, test:frontend, build. - package.json: engines.node>=20, test:frontend/typecheck scripts. vitest unit + component tests (jsdom, @testing-library/react): - utils/charts.test.ts — tier math + every chart builder. - utils/format.test.ts — parseAppDate, formatNumber, truncateKey, emoji helpers, formatRelativeTime. - components/Navbar.test.tsx — feature-gated nav, custom pages, OIDC/maintenance auth gating. - components/Announcements.test.tsx — banner rendering, ordering, dismiss + sessionStorage (covers behaviour moved out of the Python suite). Navbar → React (full SPA shell): - New Navbar/ThemeToggle/Announcements components + useNavItems hook (shared feature-gated nav for desktop + mobile); nav uses react-router NavLink (client-side nav + auto active) — drops the imperative data-nav-link bridge. - main.tsx single root; App.tsx renders Navbar + Announcements above routed
. - spa.html slimmed to SEO/config/footer shell (Jinja2 navbar/banners/theme script removed; #app is a plain div React fills). - Backend: _build_config_json exposes system_announcement/network_announcement. Tests (server-rendered nav/banner assertions → config): - conftest.py: get_app_config() helper (robust __APP_CONFIG__ extraction). - test_features/app/home/pages/dashboard rewritten to assert __APP_CONFIG__; dashboard client-rendered-stats tests replaced with a shell assertion. Docs: REACT_MIGRATION.md Phase 5 (incl. deliberately-skipped react-query/ Storybook/Playwright), AGENTS.md Frontend section. Verified: tsc clean, npm run build, vitest (49 passed), pytest tests/test_web (251 passed), full suite (1459 passed), pre-commit (passed). --- .github/workflows/ci.yml | 25 + AGENTS.md | 18 +- REACT_MIGRATION.md | 40 +- package-lock.json | 1128 ++++++++++++++++- package.json | 14 +- src/meshcore_hub/web/app.py | 2 + .../web/static/js/spa-react/App.tsx | 31 +- .../components/Announcements.test.tsx | 86 ++ .../js/spa-react/components/Announcements.tsx | 62 + .../js/spa-react/components/MobileNav.tsx | 60 +- .../js/spa-react/components/Navbar.test.tsx | 119 ++ .../static/js/spa-react/components/Navbar.tsx | 69 + .../js/spa-react/components/ThemeToggle.tsx | 41 + .../static/js/spa-react/hooks/useNavItems.tsx | 107 ++ .../web/static/js/spa-react/main.tsx | 22 +- .../static/js/spa-react/test/makeConfig.ts | 28 + .../web/static/js/spa-react/test/setup.ts | 7 + .../web/static/js/spa-react/types/config.ts | 2 + .../static/js/spa-react/utils/charts.test.ts | 255 ++++ .../static/js/spa-react/utils/format.test.ts | 138 ++ src/meshcore_hub/web/templates/spa.html | 106 +- tests/test_web/conftest.py | 19 +- tests/test_web/test_app.py | 122 +- tests/test_web/test_dashboard.py | 24 +- tests/test_web/test_features.py | 117 +- tests/test_web/test_home.py | 7 - tests/test_web/test_pages.py | 28 +- vitest.config.ts | 22 + 28 files changed, 2305 insertions(+), 394 deletions(-) create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/test/setup.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18d3eb8..260d7ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,31 @@ jobs: - name: Run pre-commit uses: pre-commit/action@v3.0.1 + frontend: + name: Frontend + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npx tsc --noEmit + + - name: Unit tests + run: npm run test:frontend + + - name: Build + run: npm run build + test: name: Test runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index cd2bdd6..5502435 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,16 +44,18 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core ex The web UI is a **React 19 + TypeScript + Vite** SPA in `src/meshcore_hub/web/static/js/spa-react/` (alias `@/` → that dir). The Jinja2 shell -(`web/templates/spa.html`) renders the navbar/SEO/`window.__APP_CONFIG__`; React mounts into -`
`. **Frontend tooling runs on the host** (not in Docker): `npm install`, -`npm run build` (Tailwind → vendor fonts → `vite build` → `static/dist/` + `assets.json`), and -`npx tsc --noEmit` (the TS gate — there is no JS linter in pre-commit). The Vite build is -required to serve the UI; there is no fallback bundle. +(`web/templates/spa.html`) renders only SEO/`window.__APP_CONFIG__`/footer; React renders the +navbar, banners, and routed pages into `
`. **Frontend tooling runs on the host** +(not in Docker): `npm install`, `npm run build` (Tailwind → vendor fonts → `vite build` → +`static/dist/` + `assets.json`), `npx tsc --noEmit` (the TS gate — there is no JS linter in +pre-commit), and `npm run test:frontend` (vitest). The Vite build is required to serve the UI; +there is no fallback bundle. ```bash npm install # host: install frontend deps npm run build # host: produce static/dist/ + assets.json npx tsc --noEmit # host: typecheck (must be clean) +npm run test:frontend # host: vitest unit + component tests ``` - Charts: **react-chartjs-2** — typed config builders in `utils/charts.ts`, wrappers in @@ -62,9 +64,15 @@ npx tsc --noEmit # host: typecheck (must be clean) That CSS ships in the Vite bundle, which `spa.html` loads in `` **before** `app.css` so the dark-mode map overrides win — don't reorder those ``s. - QR codes: **react-qr-code**. +- Navbar/shell: React (`components/Navbar.tsx`, `ThemeToggle.tsx`, `Announcements.tsx`, + `hooks/useNavItems.tsx`); nav uses react-router `NavLink` (client-side nav). Feature flags, + custom pages, and announcements all come from `window.__APP_CONFIG__`. - Page conventions: `useSearchParams()` for filters/pagination/sort, typed `apiGet()` with an `AbortController` in `useEffect`, `usePageTitle('entities.x')`, shared components (`Pagination`, `FilterForm`, `StatCard`, `NodeDisplay`, etc.). +- Tests: **vitest** + `@testing-library/react` (`*.test.ts(x)` next to code; setup in + `spa-react/test/`). Python web tests assert the embedded `__APP_CONFIG__` + (`tests/test_web/conftest.py::get_app_config`), not server-rendered nav HTML. - Only **fonts** are vendored (`build.js` copies them); chart/map/QR libs are bundled by Vite. ## Tests & Quality diff --git a/REACT_MIGRATION.md b/REACT_MIGRATION.md index 3eecf59..a4bf4be 100644 --- a/REACT_MIGRATION.md +++ b/REACT_MIGRATION.md @@ -10,7 +10,7 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite. | 2 | Convert pages one-by-one from LitBridge to native React | **Complete** | | 3 | Chart & map components (react-chartjs-2, react-leaflet) | **Complete** | | 4 | Cleanup (remove lit-html, old spa/, LitBridge, @legacy alias) | **Complete** | -| 5 | Optional enhancements (tests, react-query, Storybook) | Not started | +| 5 | Frontend CI + vitest unit/component tests + navbar → React (SPA shell) | **Complete** | > **Phase 3 status:** All `window.Chart` / `window.L` / `window.QRCode` globals and the > `charts.js` helper script are gone. Charts now use **react-chartjs-2** (typed builders in @@ -199,13 +199,39 @@ Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `as - (Vendor script tags / `charts.js` / `build.js` vendor copy were already removed in Phase 3.) - Updated `AGENTS.md` with the React frontend conventions. -## Phase 5: Optional Enhancements +## Phase 5: Frontend CI, Tests & SPA Shell — Complete -- Add `vitest` + `@testing-library/react` for component tests -- Add Playwright for E2E browser tests -- Consider `@tanstack/react-query` for data fetching -- Consider moving navbar from Jinja2 to React (full SPA shell) -- Add Storybook for component development +- **Frontend CI job** (`.github/workflows/ci.yml`): `npm ci` → `tsc --noEmit` → + `npm run test:frontend` → `npm run build` on every push/PR. Closes the gap where the + ~9k lines of TSX had no CI coverage (pre-commit is Python-only). +- **vitest** (`vitest.config.ts`, jsdom env, `npm run test:frontend`): + - `utils/charts.test.ts` — tier math (`routeQualityToTier`, `averageRouteTier`) and every + chart builder (empty → null, dataset counts/labels/colors, stacked %, route-strip segments). + - `utils/format.test.ts` — `parseAppDate`, `formatNumber`, `truncateKey`, `typeEmoji`, + `extractFirstEmoji`, `getNodeEmoji`, `formatRelativeTime`. + - `components/Navbar.test.tsx` — feature-gated nav links, custom pages, OIDC/maintenance + auth gating (rendered with `MemoryRouter` + `AppConfigProvider`). + - `components/Announcements.test.tsx` — system/network banner rendering, ordering, dismiss + + sessionStorage persistence (covers behaviour that moved out of the Python suite). +- **Navbar → React (full SPA shell)**: + - New `components/Navbar.tsx`, `components/ThemeToggle.tsx`, `components/Announcements.tsx`, + and `hooks/useNavItems.tsx` (shared feature-gated nav list used by desktop + mobile). + - `main.tsx` now renders a single root; `App.tsx` renders `` + `` + above the routed `
`. Nav uses react-router `NavLink` (client-side nav + auto active + class) — the imperative `data-nav-link` active-toggle and `#nav-loading` DOM bridge are gone. + - `spa.html` slimmed to a thin shell: the Jinja2 navbar, banners, and vanilla theme-toggle + script were removed; `
` became a plain `
` that React fills. + SEO ``, footer, and the early theme-init script stay server-rendered. + - Backend: `_build_config_json` now exposes `system_announcement` / `network_announcement` + (pre-rendered Markdown) for the React banners. + - Python tests that asserted the server-rendered navbar/banners were rewritten to assert the + embedded `__APP_CONFIG__` (new `get_app_config()` helper in `tests/test_web/conftest.py`); + the flag→render path is now covered by the Navbar component test. + +**Deliberately not done** (low ROI / high risk for this codebase): `@tanstack/react-query` +(conflicts with the deliberate `private, no-cache` + server-side Redis invalidation design and +is a 15-page refactor), Storybook (single-app component set), and Playwright E2E (needs the full +stack in CI; revisit if real browser coverage is wanted). ## Running & Testing diff --git a/package-lock.json b/package-lock.json index 7fe1b30..c5a9d8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,14 +23,77 @@ "tailwindcss": "^4" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.17", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4", + "jsdom": "^29.1.1", "typescript": "^5.8", - "vite": "^6" + "vite": "^6", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -322,6 +385,177 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "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.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.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.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fontsource-variable/ibm-plex-sans": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", @@ -1123,6 +1357,13 @@ "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/@tailwindcss/cli": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", @@ -1456,6 +1697,91 @@ "node": ">= 20" } }, + "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", + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "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": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "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/@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" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1501,6 +1827,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.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1566,6 +1910,162 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "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.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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", + "engines": { + "node": ">=8" + } + }, + "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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/baseline-browser-mapping": { "version": "2.10.44", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz", @@ -1579,6 +2079,16 @@ "node": ">=6.0.0" } }, + "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/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1646,6 +2156,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/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", @@ -1678,6 +2198,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/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", @@ -1694,6 +2235,20 @@ "url": "https://github.com/saadeghi/daisyui?sponsor=1" } }, + "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", @@ -1712,6 +2267,23 @@ } } }, + "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/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/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1721,6 +2293,13 @@ "node": ">=8" } }, + "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" + }, "node_modules/electron-to-chromium": { "version": "1.5.394", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz", @@ -1741,6 +2320,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1751,6 +2350,26 @@ "node": ">=6" } }, + "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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1812,6 +2431,19 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -1861,6 +2493,16 @@ "@babel/runtime": "^7.23.2" } }, + "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-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1891,6 +2533,13 @@ "node": ">=0.12.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/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1906,6 +2555,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "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.3", + "@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.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "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.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2209,6 +2909,16 @@ "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", + "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", @@ -2218,6 +2928,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -2243,6 +2960,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2303,6 +3030,40 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/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", @@ -2351,6 +3112,28 @@ "node": "^10 || ^12 || >=14" } }, + "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", + "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/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" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2362,6 +3145,16 @@ "react-is": "^16.13.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode-generator": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", @@ -2490,6 +3283,30 @@ } } }, + "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/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2535,6 +3352,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", @@ -2557,6 +3387,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2566,6 +3403,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/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/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2585,6 +3456,23 @@ "url": "https://opencollective.com/webpack" } }, + "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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2602,6 +3490,36 @@ "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.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2614,6 +3532,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2628,6 +3572,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3218,6 +4172,96 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.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.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -3227,6 +4271,88 @@ "node": ">=0.10.0" } }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index d04bd44..725f724 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,27 @@ { "private": true, "type": "module", + "engines": { + "node": ">=20" + }, "scripts": { "build": "node build.js", - "dev": "vite --config vite.config.ts" + "dev": "vite --config vite.config.ts", + "test:frontend": "vitest run", + "typecheck": "tsc --noEmit" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.17", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4", + "jsdom": "^29.1.1", "typescript": "^5.8", - "vite": "^6" + "vite": "^6", + "vitest": "^4.1.10" }, "dependencies": { "@fontsource-variable/ibm-plex-sans": "^5", diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index a48d38d..01c6104 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -361,6 +361,8 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "locale_version": getattr(app.state, "locale_version", ""), "system_maintenance": app.state.system_maintenance, "spam_score_threshold": app.state.spam_score_threshold, + "system_announcement": app.state.system_announcement, + "network_announcement": app.state.network_announcement, } role_names = { diff --git a/src/meshcore_hub/web/static/js/spa-react/App.tsx b/src/meshcore_hub/web/static/js/spa-react/App.tsx index 52f70ad..d45e67a 100644 --- a/src/meshcore_hub/web/static/js/spa-react/App.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/App.tsx @@ -9,6 +9,8 @@ import { } from "react-router"; import { useAppConfig } from "@/context/AppConfigContext"; import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { Navbar } from "@/components/Navbar"; +import { Announcements } from "@/components/Announcements"; import { HomePage } from "@/pages/Home"; import { DashboardPage } from "@/pages/Dashboard"; import { Nodes } from "@/pages/Nodes"; @@ -33,21 +35,6 @@ function useNavActiveState() { useEffect(() => { const pathname = location.pathname; - document.querySelectorAll("[data-nav-link]").forEach((link) => { - const href = link.getAttribute("href"); - let isActive = false; - if (href === "/") { - isActive = pathname === "/"; - } else if (href === "/nodes") { - isActive = pathname.startsWith("/nodes"); - } else if (href) { - isActive = pathname === href || pathname.startsWith(href + "/"); - } - link.classList.toggle("active", isActive); - }); - - const loader = document.getElementById("nav-loading"); - if (loader) loader.classList.add("hidden"); if (document.activeElement?.closest(".dropdown")) { (document.activeElement as HTMLElement).blur(); @@ -266,10 +253,22 @@ function AppRoutes() { ); } +function Shell() { + return ( + <> + + +
+ +
+ + ); +} + export function App() { return ( - + ); } diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx new file mode 100644 index 0000000..54544eb --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { Announcements } from "@/components/Announcements"; +import { makeConfig } from "@/test/makeConfig"; +import type { AppConfig } from "@/types/config"; + +function renderAnnouncements(config: AppConfig) { + return render( + + + , + ); +} + +beforeEach(() => { + sessionStorage.clear(); +}); + +describe("Announcements", () => { + it("renders nothing when there are no announcements", () => { + const { container } = renderAnnouncements(makeConfig()); + expect(container.firstChild).toBeNull(); + }); + + it("renders the system banner content as HTML", () => { + const { container } = renderAnnouncements( + makeConfig({ system_announcement: "Outage at 22:00" }), + ); + expect(container.querySelector("#system-banner")).not.toBeNull(); + expect(screen.getByText("Outage").tagName).toBe("STRONG"); + }); + + it("renders the network banner with a dismiss button", () => { + const { container } = renderAnnouncements( + makeConfig({ network_announcement: "

Notice

" }), + ); + expect(container.querySelector("#flash-banner")).not.toBeNull(); + expect(screen.getByLabelText("Dismiss")).toBeInTheDocument(); + }); + + it("does not render a dismiss control on the system banner", () => { + const { container } = renderAnnouncements( + makeConfig({ system_announcement: "Heads up" }), + ); + const banner = container.querySelector("#system-banner"); + expect(banner).not.toBeNull(); + expect(banner!.querySelector("button")).toBeNull(); + }); + + it("renders the system banner above the network banner", () => { + const { container } = renderAnnouncements( + makeConfig({ + system_announcement: "System notice", + network_announcement: "Network notice", + }), + ); + const system = container.querySelector("#system-banner"); + const network = container.querySelector("#flash-banner"); + expect(system).not.toBeNull(); + expect(network).not.toBeNull(); + // network follows system in document order + expect( + system!.compareDocumentPosition(network!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("dismisses the network banner and persists to sessionStorage", () => { + const { container } = renderAnnouncements( + makeConfig({ network_announcement: "Notice" }), + ); + fireEvent.click(screen.getByLabelText("Dismiss")); + expect(container.querySelector("#flash-banner")).toBeNull(); + expect(sessionStorage.getItem("flash-banner-dismissed")).toBe("1"); + }); + + it("does not render a previously dismissed network banner", () => { + sessionStorage.setItem("flash-banner-dismissed", "1"); + const { container } = renderAnnouncements( + makeConfig({ network_announcement: "Notice" }), + ); + expect(container.querySelector("#flash-banner")).toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx new file mode 100644 index 0000000..55437f8 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Announcements.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; + +import { useAppConfig } from "@/context/AppConfigContext"; + +export function Announcements() { + const config = useAppConfig(); + const [dismissed, setDismissed] = useState(() => { + try { + return sessionStorage.getItem("flash-banner-dismissed") === "1"; + } catch { + return false; + } + }); + + const system = config.system_announcement; + const network = config.network_announcement; + + const dismiss = () => { + setDismissed(true); + try { + sessionStorage.setItem("flash-banner-dismissed", "1"); + } catch { + // ignore + } + }; + + if (!system && (!network || dismissed)) return null; + + return ( + <> + {system && ( +
+
+
+ )} + {network && !dismissed && ( +
+
+ +
+ )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx index be85312..f9a5c48 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx @@ -1,61 +1,21 @@ -import { useTranslation } from "react-i18next"; -import { useAppConfig } from "@/context/AppConfigContext"; -import { - IconHome, - IconDashboard, - IconNodes, - IconAdvertisements, - IconMessages, - IconPackets, - IconMap, - IconMembers, - IconPage, - IconChannel, - IconPath, -} from "@/components/icons"; +import { NavLink } from "react-router"; + +import { useNavItems } from "@/hooks/useNavItems"; export function MobileNav() { - const { t } = useTranslation(); - const config = useAppConfig(); - const features = config.features ?? {}; - const customPages = config.custom_pages ?? []; - - const items: { href: string; icon: React.ReactNode; label: string }[] = [ - { href: "/", icon: , label: t("entities.home") }, - ]; - - if (features.dashboard !== false) - items.push({ href: "/dashboard", icon: , label: t("entities.dashboard") }); - if (features.nodes !== false) - items.push({ href: "/nodes", icon: , label: t("entities.nodes") }); - if (features.advertisements !== false) - items.push({ href: "/advertisements", icon: , label: t("entities.advertisements") }); - if (features.routes !== false) - items.push({ href: "/routes", icon: , label: t("entities.routes") }); - if (features.channels !== false) - items.push({ href: "/channels", icon: , label: t("entities.channels") }); - if (features.messages !== false) - items.push({ href: "/messages", icon: , label: t("entities.messages") }); - if (features.packets !== false) - items.push({ href: "/packets", icon: , label: t("entities.packets") }); - if (features.map !== false) - items.push({ href: "/map", icon: , label: t("entities.map") }); - if (features.members !== false) - items.push({ href: "/members", icon: , label: t("entities.members") }); - - if (features.pages !== false) { - for (const page of customPages) { - items.push({ href: page.url, icon: , label: page.title }); - } - } + const items = useNavItems("h-5 w-5"); return ( <> {items.map((item) => (
  • - + (isActive ? "active" : undefined)} + > {item.icon} {item.label} - +
  • ))} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx new file mode 100644 index 0000000..acd7ba5 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { Navbar } from "@/components/Navbar"; +import { makeConfig } from "@/test/makeConfig"; +import type { AppConfig } from "@/types/config"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +function renderNavbar(config: AppConfig) { + return render( + + + + + , + ); +} + +// Each nav label renders twice (desktop menu + mobile dropdown). +const labelCount = (label: string) => screen.queryAllByText(label).length; + +describe("Navbar feature gating", () => { + it("renders all feature links when every feature is enabled", () => { + renderNavbar(makeConfig()); + expect(labelCount("entities.home")).toBeGreaterThan(0); + expect(labelCount("entities.dashboard")).toBeGreaterThan(0); + expect(labelCount("entities.nodes")).toBeGreaterThan(0); + expect(labelCount("entities.messages")).toBeGreaterThan(0); + expect(labelCount("entities.map")).toBeGreaterThan(0); + }); + + it("hides links for disabled features", () => { + renderNavbar( + makeConfig({ + features: { dashboard: false, nodes: false, map: false }, + }), + ); + expect(labelCount("entities.dashboard")).toBe(0); + expect(labelCount("entities.nodes")).toBe(0); + expect(labelCount("entities.map")).toBe(0); + // Still-enabled features remain + expect(labelCount("entities.messages")).toBeGreaterThan(0); + expect(labelCount("entities.home")).toBeGreaterThan(0); + }); + + it("shows only Home when all features are off (maintenance)", () => { + renderNavbar( + makeConfig({ + system_maintenance: true, + features: { + dashboard: false, + nodes: false, + advertisements: false, + routes: false, + channels: false, + messages: false, + packets: false, + map: false, + members: false, + pages: false, + }, + }), + ); + expect(labelCount("entities.home")).toBeGreaterThan(0); + expect(labelCount("entities.dashboard")).toBe(0); + expect(labelCount("entities.nodes")).toBe(0); + expect(labelCount("entities.messages")).toBe(0); + }); + + it("renders custom pages when the pages feature is enabled", () => { + renderNavbar( + makeConfig({ + custom_pages: [ + { slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 }, + ], + }), + ); + expect(labelCount("About Us")).toBeGreaterThan(0); + }); + + it("hides custom pages when the pages feature is disabled", () => { + renderNavbar( + makeConfig({ + features: { pages: false }, + custom_pages: [ + { slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 }, + ], + }), + ); + expect(labelCount("About Us")).toBe(0); + }); +}); + +describe("Navbar auth gating", () => { + it("shows the login button when OIDC is enabled and not in maintenance", () => { + renderNavbar(makeConfig({ oidc_enabled: true })); + expect(labelCount("auth.login")).toBeGreaterThan(0); + }); + + it("hides auth when OIDC is disabled", () => { + renderNavbar(makeConfig({ oidc_enabled: false })); + expect(labelCount("auth.login")).toBe(0); + }); + + it("hides auth in maintenance mode even when OIDC is enabled", () => { + renderNavbar( + makeConfig({ oidc_enabled: true, system_maintenance: true }), + ); + expect(labelCount("auth.login")).toBe(0); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx new file mode 100644 index 0000000..6cfef9e --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Navbar.tsx @@ -0,0 +1,69 @@ +import { NavLink } from "react-router"; + +import { useAppConfig } from "@/context/AppConfigContext"; +import { useNavItems } from "@/hooks/useNavItems"; +import { AuthSection } from "@/components/AuthSection"; +import { MobileNav } from "@/components/MobileNav"; +import { ThemeToggle } from "@/components/ThemeToggle"; + +export function Navbar() { + const config = useAppConfig(); + const items = useNavItems("h-4 w-4"); + const logoClass = `theme-logo${ + config.logo_invert_light ? " theme-logo--invert-light" : "" + } h-6 w-6 mr-2`; + + return ( +
    +
    + + {config.network_name} + {config.network_name} + +
    +
    +
      + {items.map((item) => ( +
    • + (isActive ? "active" : undefined)} + > + {item.icon} {item.label} + +
    • + ))} +
    +
    +
    + + {config.oidc_enabled && !config.system_maintenance && } +
    +
    + + + +
    +
      + +
    +
    +
    +
    + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx new file mode 100644 index 0000000..996dfd5 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ThemeToggle.tsx @@ -0,0 +1,41 @@ +import { useState, type ChangeEvent } from "react"; + +export function ThemeToggle() { + const [isLight, setIsLight] = useState( + () => document.documentElement.getAttribute("data-theme") === "light", + ); + + const handleChange = (e: ChangeEvent) => { + const light = e.currentTarget.checked; + const theme = light ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", theme); + try { + localStorage.setItem("meshcore-theme", theme); + } catch { + // ignore + } + setIsLight(light); + }; + + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx b/src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx new file mode 100644 index 0000000..1f13888 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/hooks/useNavItems.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { useAppConfig } from "@/context/AppConfigContext"; +import { + IconAdvertisements, + IconChannel, + IconDashboard, + IconHome, + IconMap, + IconMembers, + IconMessages, + IconNodes, + IconPackets, + IconPage, + IconPath, +} from "@/components/icons"; + +export interface NavItem { + href: string; + label: string; + icon: ReactNode; + end?: boolean; +} + +export function useNavItems(sizeClass = "h-5 w-5"): NavItem[] { + const { t } = useTranslation(); + const config = useAppConfig(); + const features = config.features ?? {}; + const customPages = config.custom_pages ?? []; + + const items: NavItem[] = [ + { + href: "/", + label: t("entities.home"), + icon: , + end: true, + }, + ]; + + if (features.dashboard !== false) + items.push({ + href: "/dashboard", + label: t("entities.dashboard"), + icon: , + }); + if (features.nodes !== false) + items.push({ + href: "/nodes", + label: t("entities.nodes"), + icon: , + }); + if (features.advertisements !== false) + items.push({ + href: "/advertisements", + label: t("entities.advertisements"), + icon: , + }); + if (features.routes !== false) + items.push({ + href: "/routes", + label: t("entities.routes"), + icon: , + }); + if (features.channels !== false) + items.push({ + href: "/channels", + label: t("entities.channels"), + icon: , + }); + if (features.messages !== false) + items.push({ + href: "/messages", + label: t("entities.messages"), + icon: , + }); + if (features.packets !== false) + items.push({ + href: "/packets", + label: t("entities.packets"), + icon: , + }); + if (features.map !== false) + items.push({ + href: "/map", + label: t("entities.map"), + icon: , + }); + if (features.members !== false) + items.push({ + href: "/members", + label: t("entities.members"), + icon: , + }); + + if (features.pages !== false) { + for (const page of customPages) { + items.push({ + href: page.url, + label: page.title, + icon: , + }); + } + } + + return items; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/main.tsx b/src/meshcore_hub/web/static/js/spa-react/main.tsx index d784dd2..3aee37a 100644 --- a/src/meshcore_hub/web/static/js/spa-react/main.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/main.tsx @@ -3,8 +3,6 @@ import { createRoot } from "react-dom/client"; import { AppConfigProvider } from "@/context/AppConfigContext"; import { initI18n } from "@/i18n"; import { App } from "@/App"; -import { AuthSection } from "@/components/AuthSection"; -import { MobileNav } from "@/components/MobileNav"; import type { AppConfig } from "@/types/config"; async function bootstrap() { @@ -21,23 +19,13 @@ async function bootstrap() { const appContainer = document.getElementById("app"); if (!appContainer) return; - const wrap = (ui: React.ReactNode) => ( + createRoot(appContainer).render( - {ui} - + + + + , ); - - createRoot(appContainer).render(wrap()); - - const authContainer = document.getElementById("auth-section"); - if (authContainer) { - createRoot(authContainer).render(wrap()); - } - - const mobileNavContainer = document.getElementById("mobile-nav"); - if (mobileNavContainer) { - createRoot(mobileNavContainer).render(wrap()); - } } bootstrap(); diff --git a/src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts b/src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts new file mode 100644 index 0000000..e6566e9 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/test/makeConfig.ts @@ -0,0 +1,28 @@ +import type { AppConfig } from "@/types/config"; + +export function makeConfig(overrides: Partial = {}): AppConfig { + return { + network_name: "TestNet", + features: {}, + custom_pages: [], + logo_url: "/logo.svg", + version: "1.0.0", + timezone: "UTC", + timezone_iana: "UTC", + default_theme: "dark", + locale: "en", + datetime_locale: "en-US", + auto_refresh_seconds: 30, + channel_labels: {}, + logo_invert_light: false, + debug: false, + locale_version: "", + system_maintenance: false, + spam_score_threshold: 0, + oidc_enabled: false, + user: null, + roles: [], + role_names: {}, + ...overrides, + }; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/test/setup.ts b/src/meshcore_hub/web/static/js/spa-react/test/setup.ts new file mode 100644 index 0000000..e262193 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/types/config.ts b/src/meshcore_hub/web/static/js/spa-react/types/config.ts index 5cd96b5..5dc9db9 100644 --- a/src/meshcore_hub/web/static/js/spa-react/types/config.ts +++ b/src/meshcore_hub/web/static/js/spa-react/types/config.ts @@ -51,6 +51,8 @@ export interface AppConfig { user: OidcUser | null; roles: string[]; role_names: Record; + system_announcement?: string | null; + network_announcement?: string | null; } declare global { diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts new file mode 100644 index 0000000..08e3e37 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/charts.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; +import type { TFunction } from "i18next"; + +import { + averageRouteTier, + buildActivityChart, + buildLineChart, + buildRouteDetailStrip, + buildRoutesTrend, + buildStackedBar, + ChartColors, + routeQualityToTier, + type ActivitySeries, + type BreakdownBucket, + type RouteOverviewEntry, +} from "@/utils/charts"; + +const t = ((key: string) => key) as unknown as TFunction; + +const dayLabel = (date: string) => + new Date(date).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + }); + +const series = (counts: number[]): ActivitySeries => ({ + data: counts.map((count, i) => ({ + date: `2026-02-0${i + 1}`, + count, + })), +}); + +describe("routeQualityToTier", () => { + it("maps clear/marginal through and everything else to failing", () => { + expect(routeQualityToTier("clear")).toBe("clear"); + expect(routeQualityToTier("marginal")).toBe("marginal"); + expect(routeQualityToTier("failing")).toBe("failing"); + expect(routeQualityToTier("unknown")).toBe("failing"); + expect(routeQualityToTier("no_coverage")).toBe("failing"); + expect(routeQualityToTier(null)).toBe("failing"); + expect(routeQualityToTier(undefined)).toBe("failing"); + }); +}); + +describe("averageRouteTier", () => { + it("falls back to failing on empty/absent history", () => { + expect(averageRouteTier(null)).toBe("failing"); + expect(averageRouteTier([])).toBe("failing"); + }); + + it("buckets the mean tier (clear=2, marginal=1, failing=0)", () => { + const q = (quality: string) => [{ quality }]; + expect(averageRouteTier(q("clear"))).toBe("clear"); + expect(averageRouteTier(q("marginal"))).toBe("marginal"); + expect(averageRouteTier(q("failing"))).toBe("failing"); + + // mean (2+1)/2 = 1.5 -> clear + expect(averageRouteTier([{ quality: "clear" }, { quality: "marginal" }])).toBe( + "clear", + ); + // mean (2+0)/2 = 1.0 -> marginal (>= 0.75) + expect(averageRouteTier([{ quality: "clear" }, { quality: "failing" }])).toBe( + "marginal", + ); + // mean (1+0)/2 = 0.5 -> failing + expect( + averageRouteTier([{ quality: "marginal" }, { quality: "failing" }]), + ).toBe("failing"); + }); +}); + +describe("buildLineChart", () => { + it("returns null for missing/empty data", () => { + expect(buildLineChart(null, "L", "b", "bg", true)).toBeNull(); + expect(buildLineChart({ data: [] }, "L", "b", "bg", true)).toBeNull(); + }); + + it("builds a single filled dataset with formatted labels", () => { + const cfg = buildLineChart( + series([5, 10]), + "Nodes", + "border", + "fill", + true, + ); + expect(cfg).not.toBeNull(); + expect(cfg!.data.labels).toEqual([dayLabel("2026-02-01"), dayLabel("2026-02-02")]); + expect(cfg!.data.datasets).toHaveLength(1); + const ds = cfg!.data.datasets[0] as { data: number[]; label: string; fill: boolean }; + expect(ds.label).toBe("Nodes"); + expect(ds.data).toEqual([5, 10]); + expect(ds.fill).toBe(true); + }); +}); + +describe("buildActivityChart", () => { + it("returns null when both series are absent", () => { + expect(buildActivityChart(null, null, t)).toBeNull(); + }); + + it("builds one dataset when only adverts are provided", () => { + const cfg = buildActivityChart(series([3, 4]), null, t); + expect(cfg).not.toBeNull(); + expect(cfg!.data.datasets).toHaveLength(1); + expect((cfg!.data.datasets[0] as { label: string }).label).toBe( + "entities.advertisements", + ); + }); + + it("builds two datasets when both series are provided", () => { + const cfg = buildActivityChart(series([3, 4]), series([1, 2]), t); + expect(cfg).not.toBeNull(); + expect(cfg!.data.datasets).toHaveLength(2); + const labels = cfg!.data.datasets.map((d) => (d as { label: string }).label); + expect(labels).toEqual(["entities.advertisements", "entities.messages"]); + }); +}); + +describe("buildStackedBar", () => { + it("returns null for empty buckets or zero total", () => { + expect(buildStackedBar(null, ["red"])).toBeNull(); + expect(buildStackedBar([], ["red"])).toBeNull(); + const zero: BreakdownBucket[] = [ + { label: "a", count: 0 }, + { label: "b", count: 0 }, + ]; + expect(buildStackedBar(zero, ["red"])).toBeNull(); + }); + + it("produces percentage datasets that sum to 100 with rawCount preserved", () => { + const buckets: BreakdownBucket[] = [ + { label: "a", count: 30 }, + { label: "b", count: 70 }, + ]; + const cfg = buildStackedBar(buckets, ["red", "blue"]); + expect(cfg).not.toBeNull(); + const datasets = cfg!.data.datasets as { + data: number[]; + rawCount: number; + backgroundColor: string; + }[]; + expect(datasets).toHaveLength(2); + expect(datasets[0].data[0] + datasets[1].data[0]).toBeCloseTo(100); + expect(datasets[0].rawCount).toBe(30); + expect(datasets[1].rawCount).toBe(70); + expect(datasets[0].backgroundColor).toBe("red"); + expect(datasets[1].backgroundColor).toBe("blue"); + }); +}); + +describe("buildRoutesTrend", () => { + it("returns null for empty routes or routes without history", () => { + expect(buildRoutesTrend(null, t)).toBeNull(); + expect(buildRoutesTrend([], t)).toBeNull(); + expect( + buildRoutesTrend([{ from_label: "A", to_label: "B", history: [] }], t), + ).toBeNull(); + }); + + it("sorts by matched_count, uses categorical tiers, and colors by average tier", () => { + const routes: RouteOverviewEntry[] = [ + { + from_label: "A", + to_label: "B", + matched_count: 1, + history: [ + { date: "2026-02-01", quality: "clear", matched_count: 1 }, + { date: "2026-02-02", quality: "clear", matched_count: 1 }, + ], + }, + { + from_label: "C", + to_label: "D", + matched_count: 9, + history: [ + { date: "2026-02-01", quality: "failing", matched_count: 9 }, + { date: "2026-02-02", quality: "failing", matched_count: 9 }, + ], + }, + ]; + const cfg = buildRoutesTrend(routes, t); + expect(cfg).not.toBeNull(); + const datasets = cfg!.data.datasets as unknown as { + label: string; + data: string[]; + borderColor: string; + _matched: number[]; + }[]; + // Higher matched_count first + expect(datasets[0].label).toBe("C \u2192 D"); + expect(datasets[0].data).toEqual(["failing", "failing"]); + expect(datasets[0].borderColor).toBe(ChartColors.quality.failing); + expect(datasets[0]._matched).toEqual([9, 9]); + expect(datasets[1].data).toEqual(["clear", "clear"]); + expect(datasets[1].borderColor).toBe(ChartColors.quality.clear); + expect(cfg!.data.labels).toEqual([ + dayLabel("2026-02-01"), + dayLabel("2026-02-02"), + ]); + }); + + it("respects maxRoutes", () => { + const routes: RouteOverviewEntry[] = Array.from({ length: 8 }, (_, i) => ({ + from_label: `A${i}`, + to_label: "B", + matched_count: i, + history: [{ date: "2026-02-01", quality: "clear", matched_count: i }], + })); + const cfg = buildRoutesTrend(routes, t, 6); + expect(cfg!.data.datasets).toHaveLength(6); + }); +}); + +describe("buildRouteDetailStrip", () => { + it("returns null for missing/empty history", () => { + expect(buildRouteDetailStrip(null, t)).toBeNull(); + expect(buildRouteDetailStrip({ data: [] }, t)).toBeNull(); + expect(buildRouteDetailStrip({}, t)).toBeNull(); + }); + + it("produces one colored segment per day", () => { + const cfg = buildRouteDetailStrip( + { + data: [ + { date: "2026-02-01", quality: "clear", matched_count: 5 }, + { date: "2026-02-02", quality: "failing", matched_count: 2 }, + ], + }, + t, + ); + expect(cfg).not.toBeNull(); + const datasets = cfg!.data.datasets as { + data: number[]; + backgroundColor: string; + _quality: string; + _matched_count: number; + }[]; + expect(datasets).toHaveLength(2); + expect(datasets[0].data).toEqual([1]); + expect(datasets[0].backgroundColor).toBe(ChartColors.quality.clear); + expect(datasets[0]._quality).toBe("clear"); + expect(datasets[0]._matched_count).toBe(5); + expect(datasets[1].backgroundColor).toBe(ChartColors.quality.failing); + expect(datasets[1]._matched_count).toBe(2); + }); + + it("falls back to no_coverage color for unknown quality", () => { + const cfg = buildRouteDetailStrip( + { data: [{ date: "2026-02-01", quality: "weird", matched_count: 0 }] }, + t, + ); + const ds = cfg!.data.datasets[0] as { backgroundColor: string }; + expect(ds.backgroundColor).toBe(ChartColors.quality.no_coverage); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts new file mode 100644 index 0000000..9b70179 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + extractFirstEmoji, + formatNumber, + formatRelativeTime, + getNodeEmoji, + parseAppDate, + truncateKey, + typeEmoji, +} from "@/utils/format"; + +const fmt = (n: number) => new Intl.NumberFormat().format(n); + +describe("parseAppDate", () => { + it("returns null for empty/invalid input", () => { + expect(parseAppDate(null)).toBeNull(); + expect(parseAppDate("")).toBeNull(); + expect(parseAppDate(" ")).toBeNull(); + expect(parseAppDate("not a date")).toBeNull(); + }); + + it("treats naive datetimes as UTC", () => { + const d = parseAppDate("2026-02-08 12:30:00"); + expect(d).not.toBeNull(); + expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00Z")); + }); + + it("preserves explicit timezone offsets", () => { + const d = parseAppDate("2026-02-08T12:30:00+02:00"); + expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00+02:00")); + }); + + it("parses date-only strings", () => { + const d = parseAppDate("2026-02-08"); + expect(d).not.toBeNull(); + expect(d!.getTime()).toBe(Date.parse("2026-02-08")); + }); +}); + +describe("formatNumber", () => { + it("returns empty string for null/undefined/empty", () => { + expect(formatNumber(null)).toBe(""); + expect(formatNumber(undefined)).toBe(""); + expect(formatNumber("")).toBe(""); + }); + + it("returns the raw string for non-numeric input", () => { + expect(formatNumber("abc")).toBe("abc"); + }); + + it("formats numbers with locale grouping", () => { + expect(formatNumber(1234)).toBe(fmt(1234)); + expect(formatNumber("1234")).toBe(fmt(1234)); + expect(formatNumber(0)).toBe(fmt(0)); + }); +}); + +describe("truncateKey", () => { + it("returns '-' for empty input", () => { + expect(truncateKey(null)).toBe("-"); + }); + + it("returns short keys unchanged", () => { + expect(truncateKey("short")).toBe("short"); + }); + + it("truncates long keys with an ellipsis", () => { + const key = "abcdefghijklmnopqrst"; + expect(truncateKey(key)).toBe("abcdefghijkl..."); + expect(truncateKey(key, 4)).toBe("abcd..."); + }); +}); + +describe("typeEmoji", () => { + it("maps node types to emoji (incl. inference from substrings)", () => { + expect(typeEmoji("chat")).toBe("\u{1F4AC}"); + expect(typeEmoji("repeater")).toBe("\u{1F4E1}"); + expect(typeEmoji("room")).toBe("\u{1FAA7}"); + expect(typeEmoji("companion")).toBe("\u{1F4F1}"); + expect(typeEmoji("Chat Node")).toBe("\u{1F4AC}"); + expect(typeEmoji("My Repeater")).toBe("\u{1F4E1}"); + }); + + it("falls back to a pin for unknown/null types", () => { + expect(typeEmoji(null)).toBe("\u{1F4CD}"); + expect(typeEmoji("sensor")).toBe("\u{1F4CD}"); + }); +}); + +describe("extractFirstEmoji", () => { + it("returns null when there is no emoji", () => { + expect(extractFirstEmoji(null)).toBeNull(); + expect(extractFirstEmoji("plain text")).toBeNull(); + }); + + it("extracts the first emoji", () => { + expect(extractFirstEmoji("\u{1F525} hot node")).toBe("\u{1F525}"); + }); +}); + +describe("getNodeEmoji", () => { + it("prefers an emoji in the node name", () => { + expect(getNodeEmoji("\u{1F680} Rocket", null)).toBe("\u{1F680}"); + }); + + it("infers from type/name when no name emoji", () => { + expect(getNodeEmoji("Living Room", null)).toBe("\u{1FAA7}"); + expect(getNodeEmoji("X", "repeater")).toBe("\u{1F4E1}"); + }); +}); + +describe("formatRelativeTime", () => { + afterEach(() => { + delete (window as { t?: unknown }).t; + }); + + const withT = () => { + window.t = (key: string) => key; + }; + + const isoAgo = (ms: number) => new Date(Date.now() - ms).toISOString(); + + it("returns empty string for empty/invalid input", () => { + withT(); + expect(formatRelativeTime(null)).toBe(""); + }); + + it("buckets elapsed time into relative labels", () => { + withT(); + expect(formatRelativeTime(isoAgo(10 * 1000))).toBe("time.less_than_minute"); + expect(formatRelativeTime(isoAgo(5 * 60 * 1000))).toBe("time.minutes_ago"); + expect(formatRelativeTime(isoAgo(3 * 60 * 60 * 1000))).toBe("time.hours_ago"); + expect(formatRelativeTime(isoAgo(2 * 24 * 60 * 60 * 1000))).toBe( + "time.days_ago", + ); + }); +}); diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index 496612e..003b2fb 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -53,94 +53,8 @@ - - - - {% if system_announcement %} -
    -
    {{ system_announcement | safe }}
    -
    - {% endif %} - - {% if network_announcement %} -
    -
    {{ network_announcement | safe }}
    - -
    - - {% endif %} - - -
    -
    + +
    @@ -191,22 +105,6 @@ window.__APP_CONFIG__ = {{ config_json|safe }}; - - - {% if asset_app_js %} diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py index 6e340cd..b091806 100644 --- a/tests/test_web/conftest.py +++ b/tests/test_web/conftest.py @@ -1,6 +1,7 @@ """Web dashboard test fixtures.""" -from typing import Any, Generator +import json +from typing import Any, Generator, cast from unittest.mock import MagicMock, patch import pytest @@ -22,6 +23,22 @@ ALL_FEATURES_ENABLED = { } +def get_app_config(html: str) -> dict[str, Any]: + """Extract the embedded ``window.__APP_CONFIG__`` object from SPA shell HTML. + + The navbar, banners, and feature-gated nav are rendered client-side by React + from this config, so web tests assert on the config rather than on + server-rendered nav HTML. + """ + marker = "window.__APP_CONFIG__ = " + start = html.index(marker) + len(marker) + script_end = html.index("", start) + # Use the last ";" before so semicolons inside JSON string values + # (e.g. announcement HTML) don't truncate the object. + end = html.rindex(";", start, script_end) + return cast(dict[str, Any], json.loads(html[start:end])) + + class MockHttpClient: """Mock HTTP client for testing web routes.""" diff --git a/tests/test_web/test_app.py b/tests/test_web/test_app.py index 0fddb1d..c9c1207 100644 --- a/tests/test_web/test_app.py +++ b/tests/test_web/test_app.py @@ -17,7 +17,7 @@ from meshcore_hub.web.app import ( create_app, ) -from .conftest import ALL_FEATURES_ENABLED, MockHttpClient +from .conftest import ALL_FEATURES_ENABLED, MockHttpClient, get_app_config @pytest.fixture @@ -330,7 +330,7 @@ class TestFlashBannerVisibility: def test_banner_present_when_announcement_set( self, mock_http_client: MockHttpClient ) -> None: - """Banner HTML is present when network_announcement is set.""" + """Banner content is exposed in the SPA config when network_announcement is set.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -340,23 +340,19 @@ class TestFlashBannerVisibility: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - html = response.text - assert 'id="flash-banner"' in html - assert "Scheduled maintenance at 22:00" in html + config = get_app_config(client.get("/").text) + assert config["network_announcement"] + assert "Scheduled maintenance at 22:00" in config["network_announcement"] def test_banner_absent_when_announcement_none(self, client: TestClient) -> None: - """Banner HTML is absent when network_announcement is not set.""" - response = client.get("/") - assert response.status_code == 200 - html = response.text - assert 'id="flash-banner"' not in html + """Banner content is absent from the config when network_announcement is not set.""" + config = get_app_config(client.get("/").text) + assert not config["network_announcement"] def test_banner_absent_for_empty_string( self, mock_http_client: MockHttpClient ) -> None: - """Banner is not shown when announcement is an empty string.""" + """Banner is not exposed when announcement is an empty string.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -366,14 +362,13 @@ class TestFlashBannerVisibility: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert 'id="flash-banner"' not in response.text + config = get_app_config(client.get("/").text) + assert not config["network_announcement"] def test_banner_absent_for_whitespace_only( self, mock_http_client: MockHttpClient ) -> None: - """Banner is not shown when announcement is whitespace-only.""" + """Banner is not exposed when announcement is whitespace-only.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -383,16 +378,15 @@ class TestFlashBannerVisibility: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert 'id="flash-banner"' not in response.text + config = get_app_config(client.get("/").text) + assert not config["network_announcement"] class TestFlashBannerMarkdown: """Tests for Markdown rendering in the flash banner.""" def test_bold_rendered(self, mock_http_client: MockHttpClient) -> None: - """Markdown bold is rendered to .""" + """Markdown bold is rendered to in the config content.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -402,12 +396,11 @@ class TestFlashBannerMarkdown: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert "important" in response.text + config = get_app_config(client.get("/").text) + assert "important" in config["network_announcement"] def test_link_rendered(self, mock_http_client: MockHttpClient) -> None: - """Markdown link is rendered to tag.""" + """Markdown link is rendered to tag in the config content.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -417,16 +410,18 @@ class TestFlashBannerMarkdown: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert 'click here' in response.text + config = get_app_config(client.get("/").text) + assert ( + 'click here' + in config["network_announcement"] + ) def test_raw_html_passed_through(self, mock_http_client: MockHttpClient) -> None: """Raw HTML in announcement is passed through by the Markdown library. This is safe because the announcement source is an operator-controlled environment variable, not user input — same trust model as custom pages - in pages.py. + in pages.py. The React banner renders it via dangerouslySetInnerHTML. """ app = create_app( api_url="http://localhost:8000", @@ -437,9 +432,8 @@ class TestFlashBannerMarkdown: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert response.status_code == 200 - assert "bold" in response.text + config = get_app_config(client.get("/").text) + assert "bold" in config["network_announcement"] class TestSystemAnnouncementBanner: @@ -448,7 +442,7 @@ class TestSystemAnnouncementBanner: def test_system_banner_present_when_set( self, mock_http_client: MockHttpClient ) -> None: - """System banner HTML is present and Markdown-rendered when set.""" + """System banner content is exposed and Markdown-rendered in the config.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -458,18 +452,19 @@ class TestSystemAnnouncementBanner: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - html = client.get("/").text - assert 'id="system-banner"' in html - assert "Outage at 22:00" in html + config = get_app_config(client.get("/").text) + assert config["system_announcement"] + assert "Outage at 22:00" in config["system_announcement"] def test_system_banner_absent_when_none(self, client: TestClient) -> None: - """System banner HTML is absent when not set.""" - assert 'id="system-banner"' not in client.get("/").text + """System banner content is absent from the config when not set.""" + config = get_app_config(client.get("/").text) + assert not config["system_announcement"] def test_system_banner_absent_for_empty_string( self, mock_http_client: MockHttpClient ) -> None: - """System banner is not shown for an empty string.""" + """System banner is not exposed for an empty string.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -479,43 +474,12 @@ class TestSystemAnnouncementBanner: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - assert 'id="system-banner"' not in client.get("/").text + config = get_app_config(client.get("/").text) + assert not config["system_announcement"] - def test_system_banner_not_dismissable( - self, mock_http_client: MockHttpClient - ) -> None: - """System banner has no dismiss button or sessionStorage script.""" - app = create_app( - api_url="http://localhost:8000", - api_key="test-api-key", - system_announcement="Heads up", - features=ALL_FEATURES_ENABLED, - ) - app.state.http_client = mock_http_client - client = TestClient(app, raise_server_exceptions=True) - - html = client.get("/").text - banner = html[html.index('id="system-banner"') :] - banner = banner[: banner.index("
    ")] - assert "Dismiss" not in banner - assert "sessionStorage" not in banner - - def test_system_banner_stacked_above_network_banner( - self, mock_http_client: MockHttpClient - ) -> None: - """System banner is rendered above the network announcement banner.""" - app = create_app( - api_url="http://localhost:8000", - api_key="test-api-key", - system_announcement="System notice", - network_announcement="Network notice", - features=ALL_FEATURES_ENABLED, - ) - app.state.http_client = mock_http_client - client = TestClient(app, raise_server_exceptions=True) - - html = client.get("/").text - assert html.index('id="system-banner"') < html.index('id="flash-banner"') + # NOTE: system-banner stacking order and the absence of a dismiss control are + # now rendering behaviour of the React component, covered by + # the frontend test suite (components/Announcements.test.tsx). class TestSystemMaintenance: @@ -532,7 +496,7 @@ class TestSystemMaintenance: assert all(value is False for value in app.state.features.values()) def test_maintenance_nav_only_home(self, mock_http_client: MockHttpClient) -> None: - """Desktop nav contains only Home (no feature links) in maintenance.""" + """Config exposes all features off in maintenance, so the React nav shows only Home.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -542,10 +506,8 @@ class TestSystemMaintenance: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - html = client.get("/dashboard").text - assert 'href="/dashboard"' not in html - assert 'href="/nodes"' not in html - assert 'href="/messages"' not in html + config = get_app_config(client.get("/dashboard").text) + assert not any(config["features"].values()) def test_maintenance_flag_in_config_json( self, mock_http_client: MockHttpClient diff --git a/tests/test_web/test_dashboard.py b/tests/test_web/test_dashboard.py index 572155f..403a91f 100644 --- a/tests/test_web/test_dashboard.py +++ b/tests/test_web/test_dashboard.py @@ -25,27 +25,19 @@ class TestDashboardPage: response = client.get("/dashboard") assert "Test Network" in response.text - def test_dashboard_displays_stats( + def test_dashboard_serves_spa_shell( self, client: TestClient, mock_http_client: MockHttpClient ) -> None: - """Test that dashboard page displays statistics.""" - response = client.get("/dashboard") - # Check for stats from mock response - assert response.status_code == 200 - # The mock returns total_nodes: 10, active_nodes: 5, etc. - # These should be displayed in the page - assert "10" in response.text # total_nodes - assert "5" in response.text # active_nodes + """The dashboard route serves the SPA shell. - def test_dashboard_displays_message_counts( - self, client: TestClient, mock_http_client: MockHttpClient - ) -> None: - """Test that dashboard page displays message counts.""" + Dashboard statistics are fetched and rendered client-side by React from + the API, so they are not present in the server-rendered shell; we assert + the mount point and embedded config instead. + """ response = client.get("/dashboard") assert response.status_code == 200 - # Mock returns total_messages: 100, messages_today: 15 - assert "100" in response.text - assert "15" in response.text + assert 'id="app"' in response.text + assert "window.__APP_CONFIG__" in response.text class TestDashboardPageAPIErrors: diff --git a/tests/test_web/test_features.py b/tests/test_web/test_features.py index 9fc29bf..4e20bab 100644 --- a/tests/test_web/test_features.py +++ b/tests/test_web/test_features.py @@ -1,12 +1,14 @@ """Tests for feature flags functionality.""" -import json - import pytest from fastapi.testclient import TestClient from meshcore_hub.web.app import create_app -from tests.test_web.conftest import ALL_FEATURES_ENABLED, MockHttpClient +from tests.test_web.conftest import ( + ALL_FEATURES_ENABLED, + MockHttpClient, + get_app_config, +) class TestFeatureFlagsConfig: @@ -16,11 +18,7 @@ class TestFeatureFlagsConfig: """All non-OIDC features should be enabled by default in config JSON.""" response = client.get("/") assert response.status_code == 200 - html = response.text - # Extract config JSON from script tag - start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ") - end = html.index(";", start) - config = json.loads(html[start:end]) + config = get_app_config(response.text) features = config["features"] non_oidc_features = {k: v for k, v in features.items() if k != "members"} assert all( @@ -30,10 +28,7 @@ class TestFeatureFlagsConfig: def test_features_dict_has_all_keys(self, client: TestClient) -> None: """Features dict should have all 7 expected keys.""" response = client.get("/") - html = response.text - start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ") - end = html.index(";", start) - config = json.loads(html[start:end]) + config = get_app_config(response.text) features = config["features"] expected_keys = { "dashboard", @@ -49,45 +44,41 @@ class TestFeatureFlagsConfig: def test_disabled_features_in_config(self, client_no_features: TestClient) -> None: """Disabled features should be false in config JSON.""" response = client_no_features.get("/") - html = response.text - start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ") - end = html.index(";", start) - config = json.loads(html[start:end]) + config = get_app_config(response.text) features = config["features"] assert all(not v for v in features.values()), "All features should be disabled" class TestFeatureFlagsNav: - """Test feature flags affect navigation.""" + """Test feature flags affect navigation (via the SPA config the nav reads).""" def test_enabled_features_show_nav_links(self, client: TestClient) -> None: - """Enabled features should show nav links.""" - response = client.get("/") - html = response.text - assert 'href="/dashboard"' in html - assert 'href="/nodes"' in html - assert 'href="/advertisements"' in html - assert 'href="/messages"' in html - assert 'href="/map"' in html + """Enabled features should be true in config so the React nav shows them.""" + config = get_app_config(client.get("/").text) + features = config["features"] + for key in ("dashboard", "nodes", "advertisements", "messages", "map"): + assert features[key] is True def test_disabled_features_hide_nav_links( self, client_no_features: TestClient ) -> None: - """Disabled features should not show nav links.""" - response = client_no_features.get("/") - html = response.text - assert 'href="/dashboard"' not in html - assert 'href="/nodes"' not in html - assert 'href="/advertisements"' not in html - assert 'href="/messages"' not in html - assert 'href="/map"' not in html - assert 'href="/members"' not in html + """Disabled features should be false in config so the React nav hides them.""" + config = get_app_config(client_no_features.get("/").text) + features = config["features"] + for key in ( + "dashboard", + "nodes", + "advertisements", + "messages", + "map", + "members", + ): + assert features[key] is False def test_home_link_always_present(self, client_no_features: TestClient) -> None: - """Home link should always be present.""" + """The SPA mount (where the always-present Home nav renders) is in the shell.""" response = client_no_features.get("/") - html = response.text - assert 'href="/"' in html + assert 'id="app"' in response.text class TestFeatureFlagsEndpoints: @@ -118,11 +109,7 @@ class TestFeatureFlagsEndpoints: self, client_no_features: TestClient ) -> None: """Custom pages should be empty in config when pages feature is disabled.""" - response = client_no_features.get("/") - html = response.text - start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ") - end = html.index(";", start) - config = json.loads(html[start:end]) + config = get_app_config(client_no_features.get("/").text) assert config["custom_pages"] == [] @@ -213,18 +200,18 @@ class TestPacketsFeatureFlag: ) -> None: """The packets nav link is absent when the feature is off.""" client = self._make_app(mock_http_client, packets=False) - html = client.get("/").text - assert 'href="/packets"' not in html + config = get_app_config(client.get("/").text) + assert config["features"]["packets"] is False # Messages still shows (ordering sanity) - assert 'href="/messages"' in html + assert config["features"]["messages"] is True def test_packets_nav_shown_when_enabled( self, mock_http_client: MockHttpClient ) -> None: """The packets nav link appears when the feature is on.""" client = self._make_app(mock_http_client, packets=True) - html = client.get("/").text - assert 'href="/packets"' in html + config = get_app_config(client.get("/").text) + assert config["features"]["packets"] is True def test_packets_enabled_by_default_in_settings(self) -> None: """The declared default for feature_packets is True (env-independent).""" @@ -266,11 +253,10 @@ class TestFeatureFlagsIndividual: def test_disable_map_only(self, _make_client) -> None: """Disabling only map should hide map but show others.""" client = _make_client("map") - response = client.get("/") - html = response.text - assert 'href="/map"' not in html - assert 'href="/dashboard"' in html - assert 'href="/nodes"' in html + config = get_app_config(client.get("/").text) + assert config["features"]["map"] is False + assert config["features"]["dashboard"] is True + assert config["features"]["nodes"] is True # Map data endpoint should 404 response = client.get("/map/data") @@ -279,11 +265,10 @@ class TestFeatureFlagsIndividual: def test_disable_dashboard_only(self, _make_client) -> None: """Disabling only dashboard should hide dashboard but show others.""" client = _make_client("dashboard") - response = client.get("/") - html = response.text - assert 'href="/dashboard"' not in html - assert 'href="/nodes"' in html - assert 'href="/map"' in html + config = get_app_config(client.get("/").text) + assert config["features"]["dashboard"] is False + assert config["features"]["nodes"] is True + assert config["features"]["map"] is True class TestDashboardAutoDisable: @@ -310,12 +295,7 @@ class TestDashboardAutoDisable: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - html = response.text - assert 'href="/dashboard"' not in html - - # Check config JSON also reflects it - config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0]) + config = get_app_config(client.get("/").text) assert config["features"]["dashboard"] is False def test_map_auto_disabled_when_nodes_off( @@ -339,12 +319,7 @@ class TestDashboardAutoDisable: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - html = response.text - assert 'href="/map"' not in html - - # Check config JSON also reflects it - config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0]) + config = get_app_config(client.get("/").text) assert config["features"]["map"] is False # Map data endpoint should 404 @@ -372,5 +347,5 @@ class TestDashboardAutoDisable: app.state.http_client = mock_http_client client = TestClient(app, raise_server_exceptions=True) - response = client.get("/") - assert 'href="/dashboard"' in response.text + config = get_app_config(client.get("/").text) + assert config["features"]["dashboard"] is True diff --git a/tests/test_web/test_home.py b/tests/test_web/test_home.py index c309d91..1eaeb28 100644 --- a/tests/test_web/test_home.py +++ b/tests/test_web/test_home.py @@ -76,13 +76,6 @@ class TestHomePage: response = client.get("/") assert "discord.gg/test" in response.text - def test_home_contains_navigation(self, client: TestClient) -> None: - """Test that home page contains navigation links.""" - response = client.get("/") - assert 'href="/"' in response.text - assert 'href="/nodes"' in response.text - assert 'href="/messages"' in response.text - def test_home_contains_spa_mount(self, client: TestClient) -> None: """Test that home page renders the React SPA mount point.""" response = client.get("/") diff --git a/tests/test_web/test_pages.py b/tests/test_web/test_pages.py index 7ab265a..692e34a 100644 --- a/tests/test_web/test_pages.py +++ b/tests/test_web/test_pages.py @@ -1,6 +1,5 @@ """Tests for custom pages functionality (SPA).""" -import json import tempfile from collections.abc import Generator from pathlib import Path @@ -10,6 +9,7 @@ import pytest from fastapi.testclient import TestClient from meshcore_hub.web.pages import CustomPage, PageLoader +from tests.test_web.conftest import get_app_config class TestCustomPage: @@ -484,32 +484,26 @@ Here are some answers. assert "Frequently Asked Questions" in data["content_html"] def test_pages_in_navigation(self, client_with_pages: TestClient) -> None: - """Test that custom pages appear in navigation.""" + """Test that custom pages are exposed for the React navigation.""" response = client_with_pages.get("/") assert response.status_code == 200 - # Check for navigation links - assert 'href="/pages/about"' in response.text - assert 'href="/pages/faq"' in response.text + config = get_app_config(response.text) + urls = [p["url"] for p in config["custom_pages"]] + assert "/pages/about" in urls + assert "/pages/faq" in urls def test_pages_sorted_in_navigation(self, client_with_pages: TestClient) -> None: - """Test that pages are sorted by menu_order in navigation.""" + """Test that pages are sorted by menu_order for the React navigation.""" response = client_with_pages.get("/") assert response.status_code == 200 + config = get_app_config(response.text) + urls = [p["url"] for p in config["custom_pages"]] # About (order 10) should appear before FAQ (order 20) - about_pos = response.text.find('href="/pages/about"') - faq_pos = response.text.find('href="/pages/faq"') - assert about_pos < faq_pos + assert urls.index("/pages/about") < urls.index("/pages/faq") def test_pages_in_config(self, client_with_pages: TestClient) -> None: """Test that custom pages are included in SPA config.""" - response = client_with_pages.get("/") - text = response.text - config_start = text.find("window.__APP_CONFIG__ = ") + len( - "window.__APP_CONFIG__ = " - ) - config_end = text.find(";", config_start) - config = json.loads(text[config_start:config_end]) - + config = get_app_config(client_with_pages.get("/").text) custom_pages = config["custom_pages"] assert len(custom_pages) == 2 slugs = [p["slug"] for p in custom_pages] diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fa4b433 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +const SPA_REACT = resolve( + __dirname, + "src/meshcore_hub/web/static/js/spa-react", +); + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": SPA_REACT, + }, + }, + test: { + environment: "jsdom", + include: ["src/meshcore_hub/web/static/js/spa-react/**/*.test.{ts,tsx}"], + setupFiles: ["src/meshcore_hub/web/static/js/spa-react/test/setup.ts"], + }, +});