mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-10 10:52:48 +02:00
feat(web): React SPA shell, markdown migration, vitest coverage, v0.17 docs
- Migrate footer, error page, and announcements to React; slim Jinja2 shell to SEO/head/config/fonts/theme-init only - Replace server-side markdown rendering with react-markdown (removes Python `markdown` dependency); custom pages + announcements ship raw markdown to the client - Add heading-anchor deep-links with CSS override for inherited colors - Extract pure helpers from pages (messageHelpers, mapMath, routesHelpers, profileHelpers, packetHelpers, packetGroupHelpers) for unit testability - Add comprehensive vitest suite: 59 test files, 315 tests covering all components, pages, and extracted helpers; global i18next mock + matchMedia stub + AbortController-aware apiMock in test infrastructure - Fix SortableTable test DOM nesting (<th> inside proper <table> context) - Update docs for v0.17.0: upgrading.md release notes, README.md project structure + build description, i18n.md path fix - Delete REACT_MIGRATION.md (migration complete, unreferenced) - Add announcements dismiss-persistence + custom-page 404 E2E specs
This commit is contained in:
@@ -1,261 +0,0 @@
|
||||
# React Migration Plan
|
||||
|
||||
Migration from lit-html (functional templates) to React 19 + TypeScript + Vite.
|
||||
|
||||
## Status
|
||||
|
||||
| Phase | Description | Status |
|
||||
|-------|-------------|--------|
|
||||
| 1 | Infrastructure (Vite, React shell, router, LitBridge, build pipeline, shared components) | **Complete** |
|
||||
| 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 | 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
|
||||
> `spa-react/utils/charts.ts` + components in `spa-react/components/charts/Charts.tsx`),
|
||||
> maps use **react-leaflet** (`MapPage.tsx`, `NodeDetail.tsx`), and QR codes use
|
||||
> **react-qr-code** (`Channels.tsx`, `NodeDetail.tsx`). Chart.js, Leaflet (+ its CSS), and
|
||||
> react-qr-code are bundled by Vite — the vendor `<script>`/`<link>` tags and the
|
||||
> `build.js` vendor copy for leaflet/chart.js/qrcodejs were removed (fonts stay vendored).
|
||||
> `spa-react/utils/charts.ts` imports `leaflet/dist/leaflet.css`; that CSS ships in the
|
||||
> Vite bundle (`asset_app_css`), which is now loaded in `<head>` **before** `app.css` so the
|
||||
> dark-mode map popup overrides in `app.css` still win.
|
||||
>
|
||||
> **Phase 2 status:** All 15 pages are converted to native React and wired into `App.tsx`.
|
||||
> The old lit-html code in `spa/` is intentionally **kept** as the `spa.html` fallback
|
||||
> (rendered only when the Vite bundle/manifest is absent) and is still referenced by
|
||||
> 5 web tests. It will be removed in Phase 4, after those tests are updated.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
- **TypeScript** strict mode, `@/` alias → `spa-react/` (the `@legacy/` alias was removed in Phase 4)
|
||||
- **Vite 6** replaces esbuild; outputs to `static/dist/` with content-hashed filenames
|
||||
- **Jinja2 shell preserved** — server renders navbar, SEO meta, config JSON; React owns `<main id="app">`
|
||||
- **All pages native React** — LitBridge (the temporary wrapper for unconverted lit-html pages) was removed in Phase 4
|
||||
- **react-i18next** loads same locale JSONs from `/static/locales/`; still exposes `window.t`
|
||||
- **Vendor scripts removed** (Phase 3): chart.js, leaflet (+ CSS), and react-qr-code are bundled by Vite; only fonts remain vendored
|
||||
- **DaisyUI + Tailwind v4** unchanged; `@source "../js/"` in input.css scans the spa-react/ source
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
vite.config.ts # Vite config (root=project, input=spa-react/index.html)
|
||||
tsconfig.json # Strict TS, path alias @/ → spa-react/
|
||||
build.js # Tailwind → vendor fonts copy → vite build → assets.json
|
||||
package.json # React 19, react-router 7, react-i18next, react-chartjs-2,
|
||||
# react-leaflet, react-qr-code, chart.js, leaflet, vite, typescript
|
||||
|
||||
src/meshcore_hub/web/static/js/spa-react/
|
||||
├── index.html # Vite HTML entry (not served; Jinja2 is the real shell)
|
||||
├── main.tsx # Bootstrap: initI18n → render App, AuthSection, MobileNav
|
||||
├── App.tsx # BrowserRouter, all routes, feature flags (native React pages)
|
||||
├── vite-env.d.ts
|
||||
├── types/config.ts # AppConfig interface, window.__APP_CONFIG__ + window.t declarations
|
||||
├── context/AppConfigContext.tsx # useAppConfig(), useFeatures(), hasRole(), channel labels
|
||||
├── i18n/index.ts # initI18n() with i18next + language detector
|
||||
├── hooks/
|
||||
│ ├── useAutoRefresh.ts # Timer-based refresh with pause/play
|
||||
│ └── usePageTitle.ts # Set document.title from entity key
|
||||
├── utils/
|
||||
│ ├── api.ts # Typed apiGet<T>, apiPost, apiPut, apiDelete, apiPostForm
|
||||
│ ├── format.ts # parseAppDate, formatDateTime, formatRelativeTime, emojis
|
||||
│ ├── charts.ts # Chart.js config builders, ChartColors, averageRouteTier (imports chart.js/auto)
|
||||
│ └── clipboard.ts # copyToClipboard with fallback
|
||||
├── components/
|
||||
│ ├── icons/index.tsx # 30+ SVG icon components (IconDashboard, IconNodes, etc.)
|
||||
│ ├── charts/Charts.tsx # react-chartjs-2 wrappers (ActivityChart, TrendLineChart, StackedBarChart, RoutesTrendChart, RouteDetailStrip)
|
||||
│ ├── Alerts.tsx # Loading, ErrorAlert, InfoAlert, SuccessAlert, WarningBadge
|
||||
│ ├── AuthSection.tsx # Navbar auth dropdown (login button or user menu)
|
||||
│ ├── MobileNav.tsx # Mobile hamburger nav items
|
||||
│ ├── ErrorBoundary.tsx # React error boundary with fallback UI
|
||||
│ ├── Pagination.tsx # URL-driven pagination (page param)
|
||||
│ ├── StatCard.tsx # Dashboard stat card with icon/color
|
||||
│ ├── NodeDisplay.tsx # Node emoji + name + description
|
||||
│ ├── FilterForm.tsx # FilterForm + FilterToggle (URL query driven)
|
||||
│ ├── SortableTable.tsx # SortableTableHeader + MobileSortSelect
|
||||
│ ├── TimezoneIndicator.tsx # Timezone abbreviation badge
|
||||
│ ├── ObserverBadges.tsx # Observer filter badges + localStorage helpers
|
||||
│ ├── RouteTypeBadge.tsx # Flood/Relay/Zero-hop badge
|
||||
│ └── JsonTree.tsx # Expandable JSON viewer
|
||||
└── pages/ # All native React pages
|
||||
├── Home.tsx, Dashboard.tsx, Nodes.tsx, NodeDetail.tsx, Advertisements.tsx,
|
||||
├── Messages.tsx, Routes.tsx, Packets.tsx, PacketDetail.tsx, PacketGroupDetail.tsx,
|
||||
├── Channels.tsx, MapPage.tsx, Members.tsx, Profile.tsx, CustomPage.tsx,
|
||||
└── NotFound.tsx, Maintenance.tsx
|
||||
```
|
||||
|
||||
> The old `src/meshcore_hub/web/static/js/spa/` lit-html tree, `LitBridge.tsx`, and
|
||||
> `legacy.d.ts` were deleted in Phase 4. There is no fallback bundle — the Vite build is required.
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# 1. npx @tailwindcss/cli build (input.css → tailwind.css)
|
||||
# 2. Copy vendor fonts (chart/map/QR libs are bundled by Vite, not vendored)
|
||||
# 3. npx vite build (bundles React + chart.js + leaflet + react-qr-code → dist/assets/)
|
||||
# 4. Remove stale dist/src/ artifact
|
||||
# 5. Generate dist/assets.json (compatible format for Jinja2 template)
|
||||
```
|
||||
|
||||
The Jinja2 template (`spa.html`) reads `assets.json` for the entry JS/CSS filenames:
|
||||
```json
|
||||
{ "app.js": "assets/index-XXXX.js", "app.css": "assets/index-XXXX.css", "vendor": {}, "locale_version": "..." }
|
||||
```
|
||||
|
||||
Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `asset_app_css` to the template.
|
||||
`asset_app_css` (which contains the bundled `leaflet.css`) is loaded in `<head>` before `app.css` so theme overrides win.
|
||||
|
||||
## Phase 2: Page Conversion
|
||||
|
||||
### Pattern for each page
|
||||
|
||||
1. Create `pages/PageName.tsx`:
|
||||
- Use `useSearchParams()` for filters/pagination/sort
|
||||
- Use typed `apiGet<T>()` with `useEffect` + `AbortController`
|
||||
- Replace lit-html `html\`...\`` with JSX
|
||||
- Use shared components (Pagination, FilterForm, StatCard, etc.)
|
||||
- Call `usePageTitle('entities.xxx')` for document title
|
||||
2. In `App.tsx`: replace `<LitPage loader={() => import("@legacy/pages/xxx.js")} />` with `<PageName />`
|
||||
3. Delete `src/meshcore_hub/web/static/js/spa/pages/xxx.js`
|
||||
4. Run `npm run build` to verify bundle compiles
|
||||
5. Run `pytest --no-cov tests/test_web/` to verify server tests still pass
|
||||
|
||||
### Conversion order (simplest → most complex)
|
||||
|
||||
| # | Page | File | Complexity | Notes |
|
||||
|---|------|------|-----------|-------|
|
||||
| 1 | NotFound | `not-found.js` | Done | Native React |
|
||||
| 2 | Maintenance | `maintenance.js` | Done | Native React |
|
||||
| 3 | Home | `home.js` | Done | Stats + nav cards + activity chart (still uses `window.createActivityChart`) |
|
||||
| 4 | CustomPage | `custom-page.js` | Done | Fetches markdown HTML → `dangerouslySetInnerHTML` |
|
||||
| 5 | Profile | `profile.js` | Done | Form + PUT + adopted nodes |
|
||||
| 6 | Members | `members.js` | Done | Profile tiles grouped by role |
|
||||
| 7 | Channels | `channels.js` | Done | Cards + admin CRUD modals + QR (`window.QRCode`) |
|
||||
| 8 | Advertisements | `advertisements.js` | Done | Table + filters + auto-refresh + observer badges |
|
||||
| 9 | Messages | `messages.js` | Done | Table + filters + auto-refresh + observer badges + dedupe |
|
||||
| 10 | Routes | `routes.js` | Done | Cards + quality + history strips (`window.createRouteDetailStrip`) + admin CRUD |
|
||||
| 11 | Nodes | `nodes.js` | Done | Table + filters + pagination + auto-refresh |
|
||||
| 12 | NodeDetail | `node-detail.js` | Done | Map (`window.L`), QR, adopt/tags CRUD |
|
||||
| 13 | Packets | `packets.js` | Done | Table + filters + auto-refresh |
|
||||
| 14 | PacketDetail | `packet-detail.js` | Done | JSON tree + raw data |
|
||||
| 15 | PacketGroupDetail | `packet-group-detail.js` | Done | Grouped receptions + path popover |
|
||||
| 16 | Dashboard | `dashboard.js` | Done | Charts (`window.initDashboardCharts`), stat cards, route health |
|
||||
| 17 | Map | `map.js` | Done | Leaflet map (`window.L`), markers, popups, filters |
|
||||
|
||||
### Key patterns in old pages → React equivalents
|
||||
|
||||
| Old pattern | React equivalent |
|
||||
|-------------|-----------------|
|
||||
| `render(container, params, router)` | Component with hooks |
|
||||
| `params.query` | `useSearchParams()` |
|
||||
| `params.signal` (AbortController) | `useEffect` cleanup + `AbortController` |
|
||||
| `router.navigate(url)` | `useNavigate()(url)` |
|
||||
| `litRender(html\`...\`, container)` | JSX return |
|
||||
| `apiGet(path, params, { signal })` | `apiGet<T>(path, params, { signal })` |
|
||||
| `getConfig()` | `useAppConfig()` |
|
||||
| `t('key')` | `useTranslation().t('key')` or `window.t('key')` |
|
||||
| `createAutoRefresh({ fetchAndRender, toggleContainer })` | `useAutoRefresh({ onRefresh })` |
|
||||
| `pagination(page, totalPages, basePath, params)` | `<Pagination page={...} totalPages={...} basePath={...} />` |
|
||||
| `renderFilterForm({ fields, basePath, navigate })` | `<FilterForm basePath={...}>...</FilterForm>` |
|
||||
| `renderStatCard({ icon, color, title, value })` | `<StatCard icon={...} color={...} title={...} value={...} />` |
|
||||
| `return () => { chart.destroy(); }` (cleanup) | `useEffect` return cleanup |
|
||||
| `window.createActivityChart(...)` | `<ActivityChart>` / `buildActivityChart` (react-chartjs-2) |
|
||||
| `window.L.map(...)` (Leaflet) | `<MapContainer>` + `useMap` controller (react-leaflet) |
|
||||
| `window.QRCode(...)` | `<QRCode>` from `react-qr-code` |
|
||||
|
||||
## Phase 3: Charts & Maps — Complete
|
||||
|
||||
- **react-chartjs-2**: typed config builders in `utils/charts.ts` (`buildLineChart`,
|
||||
`buildActivityChart`, `buildStackedBar`, `buildRoutesTrend`, `buildRouteDetailStrip`,
|
||||
plus `ChartColors`, `averageRouteTier`, `routeQualityToTier`); React wrappers in
|
||||
`components/charts/Charts.tsx` (`ActivityChart`, `TrendLineChart`, `StackedBarChart`,
|
||||
`RoutesTrendChart`, `RouteDetailStrip`). `utils/charts.ts` imports `chart.js/auto` (registers
|
||||
everything) — replaces the old global `charts.js`.
|
||||
- **react-leaflet**: `MapPage.tsx` rewritten with `<MapContainer>/<TileLayer>/<Marker>/<Popup>`
|
||||
+ a `MapController` (useMap) for fit-bounds and a memoized marker list; `NodeDetail.tsx`
|
||||
static hero map with `divIcon` marker + `OffsetCenter` (useMap). Both `import "leaflet/dist/leaflet.css"`.
|
||||
- **react-qr-code**: replaces `window.QRCode` in `Channels.tsx` and `NodeDetail.tsx`.
|
||||
- Removed leaflet/chart.js/qrcodejs `@script`/`@link` tags and `charts.js` from `spa.html`;
|
||||
deleted `charts.js`; removed their copy steps from `build.js` (fonts still vendored).
|
||||
- Moved the Vite CSS bundle (`asset_app_css`) into `<head>` **before** `app.css` so app.css's
|
||||
dark-mode Leaflet overrides win over the now-bundled leaflet.css.
|
||||
- Updated `tests/test_web/test_caching.py` (charts.js-specific tests removed; generic JS-cache
|
||||
tests point at `spa/app.js`).
|
||||
|
||||
## Phase 4: Cleanup — Complete
|
||||
|
||||
- Removed `lit-html` **and** `qrcodejs` from package.json (both unused after Phases 2–3).
|
||||
- Deleted `LitBridge.tsx`, `legacy.d.ts`, and the entire `src/meshcore_hub/web/static/js/spa/` tree.
|
||||
- Removed the `@legacy` alias from `vite.config.ts` and `tsconfig.json`.
|
||||
- Removed the lit-html fallback `{% else %}` branch from `spa.html` — the Vite build is now
|
||||
required (no fallback bundle).
|
||||
- Updated the web tests that referenced the fallback: `test_home/advertisements/nodes/messages.py`
|
||||
now assert the React mount point (`id="app"`); `test_caching.py` JS-cache tests are header-only
|
||||
(static JS is bundled into `dist/`, absent in test env) and the dist-bundle test drops the
|
||||
fallback branch.
|
||||
- (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: Frontend CI, Tests & SPA Shell — Complete
|
||||
|
||||
- **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 `<Navbar/>` + `<Announcements/>`
|
||||
above the routed `<main>`. 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; `<main id="app">` became a plain `<div id="app">` that React fills.
|
||||
SEO `<head>`, 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
|
||||
|
||||
```bash
|
||||
# Build frontend (produces static/dist/)
|
||||
npm run build
|
||||
|
||||
# Run Python web tests (verifies Jinja2 template, proxy, caching)
|
||||
source .venv/bin/activate
|
||||
pytest --no-cov tests/test_web/
|
||||
|
||||
# Quality checks
|
||||
pre-commit run --all-files
|
||||
|
||||
# Docker build (user does this manually)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core build
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The old `spa/app.js` is NO LONGER LOADED. The Jinja2 template now loads the Vite-built React bundle.
|
||||
- The Jinja2 template still renders the navbar, footer, banners, and theme toggle. Vendor chart/map/QR scripts are gone (bundled by Vite); only fonts remain vendored.
|
||||
- `window.__APP_CONFIG__` is still injected by Jinja2 and read by React on bootstrap.
|
||||
- The theme toggle in the navbar is still vanilla JS (in spa.html). React doesn't manage it.
|
||||
- No more `window.Chart` / `window.L` / `window.QRCode` globals — charts, maps, and QR codes are bundled React components (Phase 3).
|
||||
- Tailwind scans `static/js/` recursively — both `spa/` and `spa-react/` classes are included.
|
||||
- The `dist/assets.json` format is unchanged from the esbuild era — Python code didn't need changes (its `vendor` map is now empty).
|
||||
@@ -247,7 +247,7 @@ meshcore-hub api
|
||||
meshcore-hub web
|
||||
```
|
||||
|
||||
> **Note:** `npm run build` compiles Tailwind CSS and copies vendor libraries (lit-html, Leaflet, Chart.js, QRCode.js) into `src/meshcore_hub/web/static/vendor/`. This step is required before the web dashboard will render correctly. In Docker, this happens automatically during the build.
|
||||
> **Note:** `npm run build` builds the React SPA via Vite (Tailwind CSS, vendor fonts, TypeScript/React bundle) into `src/meshcore_hub/web/static/dist/`. Chart.js, Leaflet, and QR libraries are bundled by Vite; only fonts are vendored. This step is required before the web dashboard will render correctly. In Docker, this happens automatically during the build.
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -318,8 +318,8 @@ meshcore-hub/
|
||||
│ ├── templates/ # Jinja2 templates (SPA shell)
|
||||
│ └── static/
|
||||
│ ├── css/ # Stylesheets (app.css, input.css, built tailwind.css)
|
||||
│ ├── vendor/ # Vendored JS/CSS libraries (built by npm run build)
|
||||
│ ├── js/spa/ # SPA frontend (ES modules, lit-html)
|
||||
│ ├── vendor/ # Vendored fonts (copied by npm run build)
|
||||
│ ├── js/spa-react/ # SPA frontend (React 19 + TypeScript + Vite)
|
||||
│ └── locales/ # Translation files (en.json)
|
||||
├── tests/ # Test suite
|
||||
├── alembic/ # Database migrations
|
||||
@@ -339,8 +339,8 @@ meshcore-hub/
|
||||
│ └── images/ # Custom images (logo.svg/png/jpg/jpeg/webp replace default logo)
|
||||
├── data/ # Runtime data directory (DATA_HOME, created at runtime)
|
||||
├── Dockerfile # Docker build configuration (multi-stage: Node.js frontend + Python)
|
||||
├── package.json # Frontend build dependencies (Tailwind, DaisyUI, lit-html, etc.)
|
||||
├── build.js # Frontend build script (Tailwind CLI + vendor copy)
|
||||
├── package.json # Frontend build dependencies (React 19, Vite, TypeScript, Tailwind, DaisyUI)
|
||||
├── build.js # Frontend build script (Tailwind CLI + vendor fonts + Vite build + assets.json)
|
||||
├── docker-compose.yml # Docker Compose base config
|
||||
├── docker-compose.dev.yml # Development overrides (port mappings)
|
||||
├── docker-compose.prod.yml # Production overrides (proxy network)
|
||||
|
||||
+10
-7
@@ -79,21 +79,24 @@ The markdown content is rendered as-is, so include your own `# Heading` if desir
|
||||
|
||||
### Supported Markdown Features
|
||||
|
||||
Pages are rendered with [Python-Markdown](https://python-markdown.github.io/) with the following extensions enabled:
|
||||
Pages are shipped as raw markdown and rendered client-side by the React SPA
|
||||
(`react-markdown` + `remark-gfm`). Raw HTML in the source is **escaped** (not
|
||||
rendered) — this is a security choice; use markdown syntax instead of inline HTML.
|
||||
|
||||
| Feature | Syntax | Notes |
|
||||
|---------|--------|-------|
|
||||
| Headings | `# H1` through `### H3` | Rendered with `.prose` styling |
|
||||
| Headings | `# H1` through `### H3` | Rendered with `.prose` styling; each heading gets an anchor `id` for deep-linking (e.g. `/pages/about#getting-started`) |
|
||||
| Bold / Italic | `**bold**`, `*italic*` | Standard Markdown |
|
||||
| Links | `[text](url)` | Relative paths supported |
|
||||
| Unordered lists | `- item` or `* item` | Nested lists supported (3 levels) |
|
||||
| Ordered lists | `1. item` | Nested lists supported (3 levels) |
|
||||
| Tables | Pipe-delimited (`\| Header \|`) | Auto-generated `<thead>`/`<tbody>` |
|
||||
| Fenced code blocks | ` ``` ` with optional language | Syntax highlighting via `codehilite` extension |
|
||||
| Unordered lists | `- item` or `* item` | Nested lists supported |
|
||||
| Ordered lists | `1. item` | Nested lists supported |
|
||||
| Tables | Pipe-delimited (`\| Header \|`) | GFM tables (thead/tbody) |
|
||||
| Fenced code blocks | ` ``` ` with optional language | Rendered as `<pre><code>` |
|
||||
| Inline code | `` `code` `` | Styled with monospace font |
|
||||
| Blockquotes | `> quote` | Left border styling |
|
||||
| Images | `` | Use absolute paths to `/media/` |
|
||||
| Table of contents | `[TOC]` marker | Auto-generated from headings |
|
||||
| Task lists | `- [ ] item` / `- [x] item` | GFM task lists |
|
||||
| Strikethrough | `~~text~~` | GFM |
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
|
||||
+1
-1
@@ -499,5 +499,5 @@ User profile page (OIDC authenticated users):
|
||||
|
||||
If you're unsure about the context of a translation key, check:
|
||||
1. The "Context" column in this reference
|
||||
2. The JavaScript files in `/src/meshcore_hub/web/static/js/spa/pages/`
|
||||
2. The JavaScript files in `/src/meshcore_hub/web/static/js/spa-react/pages/`
|
||||
3. Grep for the key: `grep -r "t('section.key')" src/`
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
|
||||
|
||||
## v0.17.0
|
||||
|
||||
### React Web UI
|
||||
|
||||
The web dashboard frontend has been rewritten from lit-html ES modules to **React 19 + TypeScript + Vite**. The entire UI — navbar, footer, banners, theme toggle, and all pages — is now rendered by React. The Jinja2 shell (`spa.html`) is now a thin bootstrap: it renders only SEO `<head>` meta, `window.__APP_CONFIG__`, font preloads, and the early theme-init script.
|
||||
|
||||
**No configuration changes, no database migration, no `.env` changes required.** All existing env vars (`FEATURE_*`, `WEB_*`, `SYSTEM_ANNOUNCEMENT`, etc.) work identically. The upgrade is purely a frontend swap — the API, collector, and all backend behaviour are unchanged.
|
||||
|
||||
What changed alongside the rewrite:
|
||||
|
||||
- The `markdown` Python dependency was removed — announcements and custom pages now ship raw markdown to the client, rendered by `react-markdown`.
|
||||
- Vendor JS libraries (Chart.js, Leaflet, react-qr-code) are bundled by Vite into `static/dist/`; only fonts remain vendored in `static/vendor/`.
|
||||
- The old `src/meshcore_hub/web/static/js/spa/` directory has been removed. The Vite build (`npm run build`) is required — there is no fallback bundle.
|
||||
- Custom CSS overrides and themes are unaffected (Tailwind/DaisyUI unchanged; `app.css` still loaded).
|
||||
|
||||
## v0.16.0
|
||||
|
||||
### Route Health Monitoring
|
||||
|
||||
@@ -11,3 +11,7 @@ This page is **rendered from markdown** served by the Playwright test stack.
|
||||
- Deterministic content mounted via `CONTENT_HOME`
|
||||
- Sourced from `e2e/content/pages/about.md`
|
||||
- Fetched by the SPA from `/spa/pages/about`
|
||||
|
||||
## Getting Started
|
||||
|
||||
Some introductory detail used as a deep-link target for the heading-anchor tests.
|
||||
|
||||
@@ -217,6 +217,8 @@ services:
|
||||
- WEB_HOST=0.0.0.0
|
||||
- WEB_PORT=8080
|
||||
- NETWORK_NAME=Test Network
|
||||
- SYSTEM_ANNOUNCEMENT=**Outage** window scheduled
|
||||
- NETWORK_ANNOUNCEMENT=**Maintenance** window tonight
|
||||
- WEB_LOCALE=en
|
||||
- WEB_THEME=dark
|
||||
- WEB_AUTO_REFRESH_SECONDS=2
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("announcements", () => {
|
||||
test("system announcement renders markdown bold", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// SYSTEM_ANNOUNCEMENT=**Outage** window scheduled is shipped as raw
|
||||
// markdown in __APP_CONFIG__ and rendered client-side by <Markdown>.
|
||||
// **Outage** must become a <strong> element end-to-end.
|
||||
const banner = page.locator("#system-banner");
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner.locator("strong")).toHaveText("Outage");
|
||||
await expect(banner).toContainText("window scheduled");
|
||||
});
|
||||
|
||||
test("network announcement dismiss persists across reload", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const banner = page.locator("#flash-banner");
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner.locator("strong")).toHaveText("Maintenance");
|
||||
|
||||
await page.getByRole("button", { name: "Dismiss" }).click();
|
||||
await expect(banner).not.toBeVisible();
|
||||
|
||||
// The dismiss flag is persisted in sessionStorage — reloading must NOT
|
||||
// bring the banner back.
|
||||
await page.reload();
|
||||
await expect(banner).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ test.describe("custom pages", () => {
|
||||
test("markdown content is rendered", async ({ page }) => {
|
||||
await page.goto("/pages/about");
|
||||
|
||||
const prose = page.locator(".prose");
|
||||
const prose = page.locator(".card-body .prose");
|
||||
await expect(prose).toBeVisible();
|
||||
await expect(
|
||||
prose.getByRole("heading", { name: "About the E2E Network" }),
|
||||
@@ -22,6 +22,34 @@ test.describe("custom pages", () => {
|
||||
await expect(link.first()).toBeVisible();
|
||||
await link.first().click();
|
||||
await expect(page).toHaveURL(/\/pages\/about/);
|
||||
await expect(page.locator(".prose")).toBeVisible();
|
||||
await expect(page.locator(".card-body .prose")).toBeVisible();
|
||||
});
|
||||
|
||||
test("heading anchor updates the URL hash on click", async ({ page }) => {
|
||||
await page.goto("/pages/about");
|
||||
|
||||
// rehype-slug assigns the id; rehype-autolink-headings (behavior: "wrap")
|
||||
// wraps the heading text in an <a href="#getting-started">.
|
||||
const anchor = page.locator("h2#getting-started a");
|
||||
await expect(anchor).toHaveAttribute("href", "#getting-started");
|
||||
await anchor.click();
|
||||
await expect(page).toHaveURL(/#getting-started$/);
|
||||
});
|
||||
|
||||
test("direct hash navigation scrolls the heading into view", async ({ page }) => {
|
||||
// Exercises the async-load scroll effect in CustomPage.tsx: the heading is
|
||||
// absent until /spa/pages/about resolves, then scrollIntoView fires.
|
||||
await page.goto("/pages/about#getting-started");
|
||||
|
||||
const heading = page.getByRole("heading", { name: "Getting Started" });
|
||||
await expect(heading).toBeInViewport();
|
||||
});
|
||||
|
||||
test("unknown slug shows a not-found error", async ({ page }) => {
|
||||
await page.goto("/pages/does-not-exist");
|
||||
|
||||
const alert = page.locator('[role="alert"]');
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(alert).toContainText(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,23 @@ test.describe("global", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("footer renders network name and attribution", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const footer = page.locator("footer");
|
||||
await expect(footer).toBeVisible();
|
||||
// Network name comes from __APP_CONFIG__ (NETWORK_NAME=Test Network).
|
||||
await expect(footer.getByText("Test Network")).toBeVisible();
|
||||
// Hub attribution link.
|
||||
await expect(
|
||||
footer.getByRole("link", { name: "MeshCore Hub" }),
|
||||
).toBeVisible();
|
||||
// Tagline (i18n key footer.tagline).
|
||||
await expect(
|
||||
footer.getByText("Off-Grid, Open-Source Encrypted Messaging"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("dark/light toggle works and persists", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// The checkbox itself is visually hidden by daisyUI's swap; click the label.
|
||||
|
||||
Generated
+1542
-6
File diff suppressed because it is too large
Load Diff
@@ -42,8 +42,12 @@
|
||||
"react-dom": "^19",
|
||||
"react-i18next": "^15",
|
||||
"react-leaflet": "^5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"react-router": "^7",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwindcss": "^4"
|
||||
},
|
||||
"allowScripts": {
|
||||
|
||||
@@ -42,7 +42,6 @@ dependencies = [
|
||||
"aiosqlite>=0.19.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"python-frontmatter>=1.0.0",
|
||||
"markdown>=3.5.0",
|
||||
"prometheus-client>=0.20.0",
|
||||
"meshcoredecoder>=0.3.2",
|
||||
"redis[hiredis]>=5.0.0",
|
||||
@@ -124,7 +123,6 @@ module = [
|
||||
"uvicorn.*",
|
||||
"alembic.*",
|
||||
"frontmatter.*",
|
||||
"markdown.*",
|
||||
"prometheus_client.*",
|
||||
"meshcoredecoder.*",
|
||||
"authlib.*",
|
||||
|
||||
+14
-33
@@ -575,24 +575,18 @@ def create_app(
|
||||
if network_announcement is not None
|
||||
else settings.network_announcement
|
||||
)
|
||||
if raw_announcement:
|
||||
import markdown
|
||||
|
||||
app.state.network_announcement = markdown.markdown(raw_announcement)
|
||||
else:
|
||||
app.state.network_announcement = None
|
||||
app.state.network_announcement = (
|
||||
raw_announcement.strip() if raw_announcement else None
|
||||
)
|
||||
|
||||
raw_system_announcement = (
|
||||
system_announcement
|
||||
if system_announcement is not None
|
||||
else settings.system_announcement
|
||||
)
|
||||
if raw_system_announcement:
|
||||
import markdown
|
||||
|
||||
app.state.system_announcement = markdown.markdown(raw_system_announcement)
|
||||
else:
|
||||
app.state.system_announcement = None
|
||||
app.state.system_announcement = (
|
||||
raw_system_announcement.strip() if raw_system_announcement else None
|
||||
)
|
||||
|
||||
app.state.system_maintenance = (
|
||||
system_maintenance
|
||||
@@ -1001,7 +995,7 @@ def create_app(
|
||||
{
|
||||
"slug": page.slug,
|
||||
"title": page.title,
|
||||
"content_html": page.content_html,
|
||||
"content_markdown": page.content_markdown,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1238,14 +1232,14 @@ def create_app(
|
||||
# --- SPA Catch-All (MUST be last) ---
|
||||
@app.api_route("/{path:path}", methods=["GET"], tags=["SPA"], response_model=None)
|
||||
async def spa_catchall(request: Request, path: str = "") -> Response:
|
||||
"""Serve the SPA shell for all non-API routes."""
|
||||
templates_inst: Jinja2Templates = request.app.state.templates
|
||||
features = request.app.state.features
|
||||
page_loader = request.app.state.page_loader
|
||||
custom_pages = (
|
||||
page_loader.get_menu_pages() if features.get("pages", True) else []
|
||||
)
|
||||
"""Serve the SPA shell for all non-API routes.
|
||||
|
||||
The shell is pure bootstrap: SEO <head>, theme-init, the embedded
|
||||
``__APP_CONFIG__`` JSON, and the Vite bundle mount point. All visual
|
||||
UI (navbar, banners, pages, footer) is rendered client-side by React
|
||||
from ``__APP_CONFIG__``.
|
||||
"""
|
||||
templates_inst: Jinja2Templates = request.app.state.templates
|
||||
config_json = _build_config_json(request.app, request)
|
||||
|
||||
return templates_inst.TemplateResponse(
|
||||
@@ -1253,19 +1247,7 @@ def create_app(
|
||||
"spa.html",
|
||||
{
|
||||
"network_name": request.app.state.network_name,
|
||||
"network_city": request.app.state.network_city,
|
||||
"network_country": request.app.state.network_country,
|
||||
"network_contact_email": request.app.state.network_contact_email,
|
||||
"network_contact_discord": request.app.state.network_contact_discord,
|
||||
"network_contact_github": request.app.state.network_contact_github,
|
||||
"network_contact_youtube": request.app.state.network_contact_youtube,
|
||||
"network_welcome_text": request.app.state.network_welcome_text,
|
||||
"network_announcement": request.app.state.network_announcement,
|
||||
"system_announcement": request.app.state.system_announcement,
|
||||
"system_maintenance": request.app.state.system_maintenance,
|
||||
"oidc_enabled": request.app.state.oidc_enabled,
|
||||
"features": features,
|
||||
"custom_pages": custom_pages,
|
||||
"logo_url": request.app.state.logo_url,
|
||||
"logo_invert_light": request.app.state.logo_invert_light,
|
||||
"version": __version__,
|
||||
@@ -1273,7 +1255,6 @@ def create_app(
|
||||
"config_json": config_json,
|
||||
"asset_app_js": request.app.state.asset_app_js,
|
||||
"asset_app_css": request.app.state.asset_app_css,
|
||||
"vendor_hashes": request.app.state.vendor_hashes,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Custom markdown pages loader for MeshCore Hub Web Dashboard."""
|
||||
"""Custom markdown pages loader for MeshCore Hub Web Dashboard.
|
||||
|
||||
Pages are stored as raw markdown and rendered client-side by the React
|
||||
``<Markdown>`` component (react-markdown + remark-gfm). The loader only
|
||||
parses the YAML frontmatter (slug/title/menu_order); the body is shipped
|
||||
verbatim so the SPA is the single source of truth for rendering.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
@@ -6,7 +12,6 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import frontmatter
|
||||
import markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,7 +23,7 @@ class CustomPage:
|
||||
slug: str
|
||||
title: str
|
||||
menu_order: int
|
||||
content_html: str
|
||||
content_markdown: str
|
||||
file_path: str
|
||||
|
||||
@property
|
||||
@@ -38,10 +43,6 @@ class PageLoader:
|
||||
"""
|
||||
self.pages_dir = Path(pages_dir)
|
||||
self._pages: dict[str, CustomPage] = {}
|
||||
self._md = markdown.Markdown(
|
||||
extensions=["tables", "fenced_code", "toc"],
|
||||
output_format="html",
|
||||
)
|
||||
|
||||
def load_pages(self) -> None:
|
||||
"""Load all markdown pages from the pages directory."""
|
||||
@@ -67,7 +68,7 @@ class PageLoader:
|
||||
logger.info(f"Loaded {len(self._pages)} custom page(s)")
|
||||
|
||||
def _load_page(self, file_path: Path) -> Optional[CustomPage]:
|
||||
"""Load a single markdown page.
|
||||
"""Load a single markdown page (frontmatter + raw body).
|
||||
|
||||
Args:
|
||||
file_path: Path to the markdown file.
|
||||
@@ -83,15 +84,11 @@ class PageLoader:
|
||||
title = post.get("title", slug.replace("-", " ").replace("_", " ").title())
|
||||
menu_order = post.get("menu_order", 100)
|
||||
|
||||
# Convert markdown to HTML
|
||||
self._md.reset()
|
||||
content_html = self._md.convert(post.content)
|
||||
|
||||
return CustomPage(
|
||||
slug=slug,
|
||||
title=title,
|
||||
menu_order=menu_order,
|
||||
content_html=content_html,
|
||||
content_markdown=post.content,
|
||||
file_path=str(file_path),
|
||||
)
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
font-weight: 700;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
@@ -200,6 +201,7 @@
|
||||
font-weight: 600;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
@@ -207,6 +209,7 @@
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
@@ -214,6 +217,7 @@
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
@@ -255,6 +259,16 @@
|
||||
color: color-mix(in oklab, var(--color-primary) 70%, var(--color-base-content));
|
||||
}
|
||||
|
||||
/* Heading anchor wrappers (rehype-autolink-headings, behavior: "wrap"):
|
||||
inherit the heading color instead of the link color, and only underline on hover. */
|
||||
.prose :is(h1, h2, h3, h4, h5, h6) > a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.prose :is(h1, h2, h3, h4, h5, h6) > a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
background: var(--color-base-200);
|
||||
padding: 0.125rem 0.25rem;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Announcements } from "@/components/Announcements";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { HomePage } from "@/pages/Home";
|
||||
import { DashboardPage } from "@/pages/Dashboard";
|
||||
import { Nodes } from "@/pages/Nodes";
|
||||
@@ -42,7 +43,9 @@ function useNavActiveState() {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
if (!location.hash) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
const networkName = config.network_name || "MeshCore Network";
|
||||
const features = config.features ?? {};
|
||||
@@ -68,7 +71,7 @@ function useNavActiveState() {
|
||||
} else {
|
||||
document.title = networkName;
|
||||
}
|
||||
}, [location.pathname, config]);
|
||||
}, [location.pathname, location.hash, config]);
|
||||
}
|
||||
|
||||
function ShortLinkRedirect() {
|
||||
@@ -263,6 +266,7 @@ function Shell() {
|
||||
<main className="container mx-auto px-4 py-6 flex-1">
|
||||
<AppRoutes />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Loading,
|
||||
ErrorAlert,
|
||||
InfoAlert,
|
||||
SuccessAlert,
|
||||
WarningBadge,
|
||||
} from "@/components/Alerts";
|
||||
|
||||
describe("Alerts", () => {
|
||||
it("Loading renders a centered spinner", () => {
|
||||
const { container } = render(<Loading />);
|
||||
expect(container.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("ErrorAlert renders an error-toned alert with the message", () => {
|
||||
render(<ErrorAlert message="Something broke" />);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toHaveClass("alert-error");
|
||||
expect(alert).toHaveTextContent("Something broke");
|
||||
});
|
||||
|
||||
it("InfoAlert and SuccessAlert render with the correct tones", () => {
|
||||
const { rerender } = render(<InfoAlert message="FYI" />);
|
||||
expect(screen.getByRole("alert")).toHaveClass("alert-info");
|
||||
rerender(<SuccessAlert message="Done" />);
|
||||
expect(screen.getByRole("alert")).toHaveClass("alert-success");
|
||||
});
|
||||
|
||||
it("WarningBadge renders a tooltip with the message", () => {
|
||||
const { container } = render(<WarningBadge message="careful" />);
|
||||
expect(container.querySelector(".badge-warning")).not.toBeNull();
|
||||
expect(container.querySelector('[data-tip="careful"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,9 @@ describe("Announcements", () => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the system banner content as HTML", () => {
|
||||
it("renders the system banner content rendered from markdown", () => {
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({ system_announcement: "<strong>Outage</strong> at 22:00" }),
|
||||
makeConfig({ system_announcement: "**Outage** at 22:00" }),
|
||||
);
|
||||
expect(container.querySelector("#system-banner")).not.toBeNull();
|
||||
expect(screen.getByText("Outage").tagName).toBe("STRONG");
|
||||
@@ -34,12 +34,21 @@ describe("Announcements", () => {
|
||||
|
||||
it("renders the network banner with a dismiss button", () => {
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({ network_announcement: "<p>Notice</p>" }),
|
||||
makeConfig({ network_announcement: "Notice" }),
|
||||
);
|
||||
expect(container.querySelector("#flash-banner")).not.toBeNull();
|
||||
expect(screen.getByLabelText("Dismiss")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the network banner content rendered from markdown", () => {
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({ network_announcement: "**Maintenance** done" }),
|
||||
);
|
||||
const banner = container.querySelector("#flash-banner");
|
||||
expect(banner).not.toBeNull();
|
||||
expect(screen.getByText("Maintenance").tagName).toBe("STRONG");
|
||||
});
|
||||
|
||||
it("does not render a dismiss control on the system banner", () => {
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({ system_announcement: "Heads up" }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
|
||||
export function Announcements() {
|
||||
@@ -33,10 +34,9 @@ export function Announcements() {
|
||||
id="system-banner"
|
||||
className="alert alert-error rounded-none py-2 px-4 text-center text-sm"
|
||||
>
|
||||
<div
|
||||
className="flash-banner-content"
|
||||
dangerouslySetInnerHTML={{ __html: system }}
|
||||
/>
|
||||
<Markdown className="flash-banner-content">
|
||||
{system}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{network && !dismissed && (
|
||||
@@ -44,10 +44,9 @@ export function Announcements() {
|
||||
id="flash-banner"
|
||||
className="alert alert-warning rounded-none py-2 px-4 text-center text-sm"
|
||||
>
|
||||
<div
|
||||
className="flash-banner-content"
|
||||
dangerouslySetInnerHTML={{ __html: network }}
|
||||
/>
|
||||
<Markdown className="flash-banner-content">
|
||||
{network}
|
||||
</Markdown>
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
onClick={dismiss}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AuthSection } from "@/components/AuthSection";
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import type { AppConfig } from "@/types/config";
|
||||
|
||||
function renderAuth(config: Partial<AppConfig> = {}) {
|
||||
return render(
|
||||
<AppConfigProvider config={makeConfig(config)}>
|
||||
<AuthSection />
|
||||
</AppConfigProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AuthSection", () => {
|
||||
it("renders nothing when OIDC is disabled", () => {
|
||||
const { container } = renderAuth({ oidc_enabled: false });
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a login link when OIDC is enabled with no user", () => {
|
||||
renderAuth({ oidc_enabled: true });
|
||||
expect(screen.getByText("auth.login").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/auth/login",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the avatar image when the user has a picture", () => {
|
||||
renderAuth({
|
||||
oidc_enabled: true,
|
||||
user: { sub: "u1", name: "Jane", picture: "pic.jpg" },
|
||||
});
|
||||
expect(screen.getByAltText("Jane")).toHaveAttribute("src", "pic.jpg");
|
||||
});
|
||||
|
||||
it("shows initials derived from the name when no picture", () => {
|
||||
renderAuth({
|
||||
oidc_enabled: true,
|
||||
user: { sub: "u1", name: "Jane Doe" },
|
||||
});
|
||||
expect(screen.getByText("JD")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders role badges from config.roles", () => {
|
||||
renderAuth({
|
||||
oidc_enabled: true,
|
||||
roles: ["admin", "operator"],
|
||||
user: { sub: "u1", name: "Jane" },
|
||||
});
|
||||
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||
expect(screen.getByText("operator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the user sub in debug mode", () => {
|
||||
renderAuth({
|
||||
oidc_enabled: true,
|
||||
debug: true,
|
||||
user: { sub: "user-abc", name: "Jane" },
|
||||
});
|
||||
expect(screen.getByText("user-abc")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AutoRefreshToggle } from "@/components/AutoRefreshToggle";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AutoRefreshToggle", () => {
|
||||
it("renders nothing when the interval is 0", () => {
|
||||
const { container } = render(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
function Thrower({ message }: { message: string }): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const originalT = window.t;
|
||||
|
||||
beforeEach(() => {
|
||||
window.t = (key: string) => key;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.t = originalT;
|
||||
});
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
it("renders children when no error is thrown", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<p>all good</p>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("all good")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the fallback UI when a child throws", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Thrower message="kaboom" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("common.error")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.failed_to_load_page")).toBeInTheDocument();
|
||||
expect(screen.getByText("kaboom")).toBeInTheDocument();
|
||||
const homeLink = screen.getByText("common.go_home");
|
||||
expect(homeLink.closest("a")).toHaveAttribute("href", "/");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs the caught error via componentDidCatch", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Thrower message="logged" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"React ErrorBoundary caught:",
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ componentStack: expect.any(String) }),
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,6 @@ import {
|
||||
submitOnEnter,
|
||||
} from "@/components/FilterForm";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
const profiles = [
|
||||
{ id: "1", name: "Alice", callsign: "AL", user_id: "u1" },
|
||||
{ id: "2", name: "Bob", callsign: null, user_id: "u2" },
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import type { AppConfig } from "@/types/config";
|
||||
|
||||
function renderFooter(config: AppConfig) {
|
||||
return render(
|
||||
<AppConfigProvider config={config}>
|
||||
<Footer />
|
||||
</AppConfigProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Footer", () => {
|
||||
it("renders the network name and version", () => {
|
||||
renderFooter(makeConfig({ network_name: "HamNet", version: "2.3.4" }));
|
||||
expect(screen.getByText("HamNet")).toBeInTheDocument();
|
||||
expect(screen.getByText(/2\.3\.4/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders city and country when both are set", () => {
|
||||
renderFooter(
|
||||
makeConfig({ network_name: "HamNet", network_city: "Berlin", network_country: "DE" }),
|
||||
);
|
||||
expect(screen.getByText("HamNet | Berlin, DE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the locale segment when city or country is missing", () => {
|
||||
renderFooter(makeConfig({ network_name: "HamNet", network_city: "Berlin" }));
|
||||
expect(screen.getByText("HamNet")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Berlin/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders contact links when provided", () => {
|
||||
renderFooter(
|
||||
makeConfig({
|
||||
network_contact_email: "op@example.com",
|
||||
network_contact_discord: "https://discord.gg/x",
|
||||
network_contact_github: "https://github.com/x",
|
||||
network_contact_youtube: "https://youtube.com/@x",
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("op@example.com")).toHaveAttribute("href", "mailto:op@example.com");
|
||||
expect(screen.getByText("links.discord")).toHaveAttribute("href", "https://discord.gg/x");
|
||||
expect(screen.getByText("links.youtube")).toHaveAttribute("href", "https://youtube.com/@x");
|
||||
// "links.github" appears twice (MeshCore project link + network contact link) — pick by href
|
||||
expect(screen.getAllByText("links.github").length).toBe(2);
|
||||
expect(
|
||||
screen.getByText("links.github", { selector: 'a[href="https://github.com/x"]' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits all contact links when none are set", () => {
|
||||
const { container } = renderFooter(makeConfig());
|
||||
const paragraphs = container.querySelectorAll("p");
|
||||
// No contact link hrefs anywhere in the footer
|
||||
expect(container.querySelector('a[href^="mailto:"]')).toBeNull();
|
||||
expect(container.querySelector('a[href*="discord"]')).toBeNull();
|
||||
expect(paragraphs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders partial contact links without stray separators", () => {
|
||||
renderFooter(
|
||||
makeConfig({
|
||||
network_contact_email: "op@example.com",
|
||||
network_contact_youtube: "https://youtube.com/@x",
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("op@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("links.youtube")).toHaveAttribute(
|
||||
"href",
|
||||
"https://youtube.com/@x",
|
||||
);
|
||||
expect(screen.queryByText("links.discord")).not.toBeInTheDocument();
|
||||
// Only the MeshCore project github link — no contact github link
|
||||
expect(screen.getAllByText("links.github").length).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to 'MeshCore Network' when network_name is empty", () => {
|
||||
renderFooter(makeConfig({ network_name: "" }));
|
||||
expect(screen.getByText("MeshCore Network")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the MeshCore Hub attribution link", () => {
|
||||
renderFooter(makeConfig());
|
||||
expect(screen.getByText("MeshCore Hub").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/ipnet-mesh/meshcore-hub",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
|
||||
export function Footer() {
|
||||
const config = useAppConfig();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const networkName = config.network_name || "MeshCore Network";
|
||||
const hasLocale = Boolean(config.network_city && config.network_country);
|
||||
|
||||
return (
|
||||
<footer className="footer p-4 bg-base-100 text-base-content mt-auto">
|
||||
<div className="flex flex-col items-center gap-1 order-2 lg:order-1">
|
||||
<a
|
||||
href="https://meshcore.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:opacity-80 transition-opacity flex mb-1"
|
||||
>
|
||||
<img
|
||||
src="/static/img/meshcore.svg"
|
||||
alt="MeshCore"
|
||||
className="theme-logo theme-logo--invert-light h-4"
|
||||
/>
|
||||
</a>
|
||||
<span className="text-xs opacity-50">{t("footer.tagline")}</span>
|
||||
<p className="text-sm opacity-70">
|
||||
<a
|
||||
href="https://meshcore.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-hover"
|
||||
>
|
||||
{t("links.website")}
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a
|
||||
href="https://github.com/meshcore-dev/MeshCore"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-hover"
|
||||
>
|
||||
{t("links.github")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1 order-1 lg:order-2">
|
||||
<p>
|
||||
{networkName}
|
||||
{hasLocale ? ` | ${config.network_city}, ${config.network_country}` : ""}
|
||||
</p>
|
||||
<p className="text-xs opacity-50">
|
||||
{t("footer.powered_by")}{" "}
|
||||
<a
|
||||
href="https://github.com/ipnet-mesh/meshcore-hub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-hover"
|
||||
>
|
||||
MeshCore Hub
|
||||
</a>{" "}
|
||||
{config.version}
|
||||
</p>
|
||||
<p className="text-sm opacity-70">
|
||||
{config.network_contact_email && (
|
||||
<a href={`mailto:${config.network_contact_email}`} className="link link-hover">
|
||||
{config.network_contact_email}
|
||||
</a>
|
||||
)}
|
||||
{config.network_contact_email && config.network_contact_discord && " | "}
|
||||
{config.network_contact_discord && (
|
||||
<a
|
||||
href={config.network_contact_discord}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-hover"
|
||||
>
|
||||
{t("links.discord")}
|
||||
</a>
|
||||
)}
|
||||
{(config.network_contact_email || config.network_contact_discord) &&
|
||||
config.network_contact_github &&
|
||||
" | "}
|
||||
{config.network_contact_github && (
|
||||
<a
|
||||
href={config.network_contact_github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-hover"
|
||||
>
|
||||
{t("links.github")}
|
||||
</a>
|
||||
)}
|
||||
{(config.network_contact_email ||
|
||||
config.network_contact_discord ||
|
||||
config.network_contact_github) &&
|
||||
config.network_contact_youtube &&
|
||||
" | "}
|
||||
{config.network_contact_youtube && (
|
||||
<a
|
||||
href={config.network_contact_youtube}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-hover"
|
||||
>
|
||||
{t("links.youtube")}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { JsonTree } from "@/components/JsonTree";
|
||||
|
||||
describe("JsonTree primitives", () => {
|
||||
it("renders string values quoted with the success color", () => {
|
||||
const { container } = render(<JsonTree value="hello" />);
|
||||
expect(container.querySelector(".text-success")).not.toBeNull();
|
||||
expect(container.textContent).toContain('"hello"');
|
||||
});
|
||||
|
||||
it("renders numbers with the warning color", () => {
|
||||
const { container } = render(<JsonTree value={42} />);
|
||||
expect(container.querySelector(".text-warning")).not.toBeNull();
|
||||
expect(container.textContent).toContain("42");
|
||||
});
|
||||
|
||||
it("renders booleans with the info color", () => {
|
||||
const { container } = render(<JsonTree value={true} />);
|
||||
expect(container.querySelector(".text-info")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders null italicized", () => {
|
||||
const { container } = render(<JsonTree value={null} />);
|
||||
expect(container.querySelector(".italic")).not.toBeNull();
|
||||
expect(container.textContent).toContain("null");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JsonTree containers", () => {
|
||||
it("renders empty objects and arrays inline", () => {
|
||||
const { container } = render(<JsonTree value={{ a: {}, b: [] }} openDepth={2} />);
|
||||
expect(container.textContent).toContain("{}");
|
||||
expect(container.textContent).toContain("[]");
|
||||
});
|
||||
|
||||
it("toggles a node via the caret button", () => {
|
||||
const { container } = render(
|
||||
<JsonTree value={{ nested: { inner: 1 } }} openDepth={2} />,
|
||||
);
|
||||
const children = container.querySelector(".json-children");
|
||||
expect(children).not.toHaveClass("hidden");
|
||||
fireEvent.click(container.querySelector(".json-toggle")!);
|
||||
expect(container.querySelector(".json-children")).toHaveClass("hidden");
|
||||
});
|
||||
|
||||
it("expandAll and collapseAll buttons control all nodes", () => {
|
||||
const { container } = render(
|
||||
<JsonTree value={{ a: { b: { c: 1 } } }} openDepth={0} />,
|
||||
);
|
||||
expect(container.querySelector(".json-children")).toHaveClass("hidden");
|
||||
fireEvent.click(screen.getByText("packets.expand_all"));
|
||||
expect(container.querySelectorAll(".json-children.hidden").length).toBe(0);
|
||||
fireEvent.click(screen.getByText("packets.collapse_all"));
|
||||
expect(container.querySelectorAll(".json-children.hidden").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("respects openDepth to auto-expand the top level", () => {
|
||||
const { container } = render(<JsonTree value={{ a: 1 }} openDepth={1} />);
|
||||
expect(container.querySelector(".json-children")).not.toHaveClass("hidden");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ListToolbar } from "@/components/ListToolbar";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
const autoRefresh = {
|
||||
paused: false,
|
||||
onToggle: () => {},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
|
||||
describe("Markdown", () => {
|
||||
it("renders bold and italic inline markup", () => {
|
||||
const { container } = render(<Markdown>{"**bold** and *italic*"}</Markdown>);
|
||||
expect(container.querySelector("strong")?.textContent).toBe("bold");
|
||||
expect(container.querySelector("em")?.textContent).toBe("italic");
|
||||
});
|
||||
|
||||
it("renders GFM tables", () => {
|
||||
const md = `
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
`;
|
||||
const { container } = render(<Markdown>{md}</Markdown>);
|
||||
const table = container.querySelector("table");
|
||||
expect(table).not.toBeNull();
|
||||
expect(container.querySelectorAll("th").length).toBe(2);
|
||||
expect(container.querySelectorAll("tbody td").length).toBe(2);
|
||||
});
|
||||
|
||||
it("renders fenced code blocks", () => {
|
||||
const md = [
|
||||
"```python",
|
||||
"def hello():",
|
||||
" pass",
|
||||
"```",
|
||||
].join("\n");
|
||||
const { container } = render(<Markdown>{md}</Markdown>);
|
||||
expect(container.querySelector("pre")).not.toBeNull();
|
||||
expect(container.querySelector("pre code")?.textContent).toContain("def hello():");
|
||||
});
|
||||
|
||||
it("renders links", () => {
|
||||
const { container } = render(
|
||||
<Markdown>{"[click](https://example.com)"}</Markdown>,
|
||||
);
|
||||
const link = container.querySelector("a");
|
||||
expect(link).toHaveAttribute("href", "https://example.com");
|
||||
expect(link?.textContent).toBe("click");
|
||||
});
|
||||
|
||||
it("assigns slug ids to headings for deep-linking", () => {
|
||||
const md = "# Getting Started\n\n## Sub Section\n";
|
||||
const { container } = render(<Markdown>{md}</Markdown>);
|
||||
expect(container.querySelector("h1")).toHaveAttribute("id", "getting-started");
|
||||
expect(container.querySelector("h2")).toHaveAttribute("id", "sub-section");
|
||||
});
|
||||
|
||||
it("wraps headings in anchor links pointing at their id", () => {
|
||||
const md = "# Getting Started\n";
|
||||
const { container } = render(<Markdown>{md}</Markdown>);
|
||||
const link = container.querySelector("h1 a");
|
||||
expect(link).toHaveAttribute("href", "#getting-started");
|
||||
});
|
||||
|
||||
it("escapes raw HTML (no rehype-raw) for safety", () => {
|
||||
const { container } = render(<Markdown>{"<b>bold</b>"}</Markdown>);
|
||||
// Raw <b> is escaped, not rendered as an element
|
||||
expect(container.querySelector("b")).toBeNull();
|
||||
expect(container.textContent).toContain("<b>bold</b>");
|
||||
});
|
||||
|
||||
it("renders external links with safe target and rel attributes", () => {
|
||||
const { container } = render(
|
||||
<Markdown>{"[click](https://example.com)"}</Markdown>,
|
||||
);
|
||||
const link = container.querySelector("a");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("does not add target/rel to heading anchor links", () => {
|
||||
const md = "# Heading\n";
|
||||
const { container } = render(<Markdown>{md}</Markdown>);
|
||||
const anchor = container.querySelector("h1 a");
|
||||
expect(anchor).toHaveAttribute("href", "#heading");
|
||||
expect(anchor).not.toHaveAttribute("target");
|
||||
expect(anchor).not.toHaveAttribute("rel");
|
||||
});
|
||||
|
||||
it("does not add target/rel to relative links", () => {
|
||||
const { container } = render(
|
||||
<Markdown>{"[about](/pages/about)"}</Markdown>,
|
||||
);
|
||||
const link = container.querySelector("a");
|
||||
expect(link).toHaveAttribute("href", "/pages/about");
|
||||
expect(link).not.toHaveAttribute("target");
|
||||
expect(link).not.toHaveAttribute("rel");
|
||||
});
|
||||
|
||||
it("applies a custom className override instead of the default prose", () => {
|
||||
const { container } = render(
|
||||
<Markdown className="flash-banner-content">{"text"}</Markdown>,
|
||||
);
|
||||
const wrapper = container.querySelector("div");
|
||||
expect(wrapper).toHaveClass("flash-banner-content");
|
||||
expect(wrapper).not.toHaveClass("prose");
|
||||
});
|
||||
|
||||
it("renders GFM task lists and strikethrough", () => {
|
||||
const md = "- [x] done\n- [ ] todo\n~~old~~\n";
|
||||
const { container } = render(<Markdown>{md}</Markdown>);
|
||||
expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
|
||||
expect(container.querySelector("del")?.textContent).toBe("old");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { memo } from "react";
|
||||
import MarkdownReact from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({
|
||||
children,
|
||||
className = "prose prose-lg max-w-none",
|
||||
}: MarkdownProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<MarkdownReact
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeSlug,
|
||||
[rehypeAutolinkHeadings, { behavior: "wrap" }],
|
||||
]}
|
||||
components={{
|
||||
a({ node: _node, href, children, ...rest }) {
|
||||
const isExternal = /^https?:\/\//i.test(href ?? "");
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MarkdownReact>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MobileNav } from "@/components/MobileNav";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
|
||||
function renderMobileNav(config = makeConfig()) {
|
||||
return renderWithProviders(<MobileNav />, { config });
|
||||
}
|
||||
|
||||
describe("MobileNav", () => {
|
||||
it("renders nav items for enabled features", () => {
|
||||
renderMobileNav();
|
||||
const links = screen.getAllByTestId("nav-link");
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
expect(links[0]).toHaveAttribute("data-nav-href", "/");
|
||||
});
|
||||
|
||||
it("hides links for disabled features", () => {
|
||||
renderMobileNav(makeConfig({ features: { map: false, members: false } }));
|
||||
const hrefs = screen
|
||||
.getAllByTestId("nav-link")
|
||||
.map((l) => l.getAttribute("data-nav-href"));
|
||||
expect(hrefs).not.toContain("/map");
|
||||
expect(hrefs).not.toContain("/members");
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,12 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } 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(
|
||||
<AppConfigProvider config={config}>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { NodeDisplay, NodeLink } from "@/components/NodeDisplay";
|
||||
|
||||
const PUBKEY = "a".repeat(64);
|
||||
|
||||
describe("NodeDisplay", () => {
|
||||
it("shows the name and an emoji when name is provided", () => {
|
||||
const { container } = render(
|
||||
<NodeDisplay name="Hub 🔌" publicKey={PUBKEY} advType="repeater" />,
|
||||
);
|
||||
expect(screen.getByText("Hub 🔌")).toHaveClass("font-medium");
|
||||
expect(container.querySelector(".text-lg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to truncated public key when name is null", () => {
|
||||
render(<NodeDisplay name={null} publicKey={PUBKEY} advType={null} />);
|
||||
expect(screen.getByText(`${PUBKEY.slice(0, 16)}...`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows description when provided", () => {
|
||||
render(
|
||||
<NodeDisplay name="X" description="A node" publicKey={PUBKEY} advType={null} />,
|
||||
);
|
||||
expect(screen.getByText("A node")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits description when not provided", () => {
|
||||
const { container } = render(
|
||||
<NodeDisplay name="X" publicKey={PUBKEY} advType={null} />,
|
||||
);
|
||||
expect(container.querySelector(".opacity-70")).toBeNull();
|
||||
});
|
||||
|
||||
it("NodeLink wraps display in a router Link to the node", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<NodeLink name="N" publicKey={PUBKEY} advType={null} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const link = container.querySelector("a");
|
||||
expect(link).toHaveAttribute("href", `/nodes/${PUBKEY}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ObserverIcons,
|
||||
ObserverFilterBadges,
|
||||
getDisabledObserverAreas,
|
||||
setDisabledObserverAreas,
|
||||
toggleObserverArea,
|
||||
} from "@/components/ObserverBadges";
|
||||
|
||||
describe("ObserverIcons", () => {
|
||||
it("renders nothing when observers is empty", () => {
|
||||
const { container } = render(<ObserverIcons observers={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the count and a tooltip with resolved names", () => {
|
||||
render(
|
||||
<ObserverIcons
|
||||
observers={[
|
||||
{ tag_name: "Alpha", public_key: "aaa" },
|
||||
{ name: "Beta", public_key: "bbb" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByText("2");
|
||||
expect(badge.closest("[title]")).toHaveAttribute("title", "Alpha, Beta");
|
||||
});
|
||||
});
|
||||
|
||||
describe("observer area localStorage helpers", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("getDisabledObserverAreas returns an empty set by default", () => {
|
||||
expect(getDisabledObserverAreas().size).toBe(0);
|
||||
});
|
||||
|
||||
it("setDisabled/getDisabled round-trip persists areas", () => {
|
||||
setDisabledObserverAreas(new Set(["north", "south"]));
|
||||
const result = getDisabledObserverAreas();
|
||||
expect(result.has("north")).toBe(true);
|
||||
expect(result.has("south")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleObserverArea adds and removes an area", () => {
|
||||
const afterAdd = toggleObserverArea("north", 3);
|
||||
expect(afterAdd.has("north")).toBe(true);
|
||||
const afterRemove = toggleObserverArea("north", 3);
|
||||
expect(afterRemove.has("north")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks disabling the last remaining area", () => {
|
||||
setDisabledObserverAreas(new Set(["north", "south"]));
|
||||
const result = toggleObserverArea("west", 3);
|
||||
expect(result.has("west")).toBe(false);
|
||||
expect(result.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObserverFilterBadges", () => {
|
||||
it("renders nothing when areas is empty", () => {
|
||||
const { container } = render(
|
||||
<ObserverFilterBadges areas={[]} disabled={new Set()} onToggle={() => {}} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders enabled and disabled badges and calls onToggle on click", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(
|
||||
<ObserverFilterBadges
|
||||
areas={["North", "South"]}
|
||||
disabled={new Set(["South"])}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
);
|
||||
const badges = screen.getAllByTestId("observer-area");
|
||||
expect(badges).toHaveLength(2);
|
||||
expect(badges[0]).toHaveAttribute("data-area", "North");
|
||||
fireEvent.click(badges[0]);
|
||||
expect(onToggle).toHaveBeenCalledWith("North");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Field,
|
||||
@@ -7,13 +7,6 @@ import {
|
||||
channelNameDisplay,
|
||||
} from "@/components/PacketParts";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Field", () => {
|
||||
it("renders a label and its value", () => {
|
||||
render(<Field label="Time">12:00</Field>);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
|
||||
function renderWithRouter(ui: React.ReactElement) {
|
||||
return render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
}
|
||||
|
||||
describe("Pagination", () => {
|
||||
it("renders nothing when totalPages <= 1", () => {
|
||||
const { container } = renderWithRouter(
|
||||
<Pagination page={1} totalPages={1} basePath="/nodes" />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("disables previous on the first page and enables next", () => {
|
||||
renderWithRouter(<Pagination page={1} totalPages={3} basePath="/nodes" />);
|
||||
expect(screen.getByText("common.previous").closest("button")).toBeDisabled();
|
||||
expect(screen.getByText("common.next").closest("a")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("disables next on the last page", () => {
|
||||
renderWithRouter(<Pagination page={3} totalPages={3} basePath="/nodes" />);
|
||||
expect(screen.getByText("common.next").closest("button")).toBeDisabled();
|
||||
expect(screen.getByText("common.previous").closest("a")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks the current page button as active", () => {
|
||||
renderWithRouter(<Pagination page={2} totalPages={3} basePath="/nodes" />);
|
||||
expect(screen.getByText("2").closest("button")).toHaveClass("btn-active");
|
||||
});
|
||||
|
||||
it("renders ellipsis for far-away pages", () => {
|
||||
renderWithRouter(<Pagination page={5} totalPages={20} basePath="/nodes" />);
|
||||
expect(screen.getAllByText("...").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("preserves extra params in page URLs", () => {
|
||||
renderWithRouter(
|
||||
<Pagination
|
||||
page={2}
|
||||
totalPages={5}
|
||||
basePath="/nodes"
|
||||
params={{ search: "foo", tag: ["a", "b"] }}
|
||||
/>,
|
||||
);
|
||||
const nextHref = screen.getByText("common.next").closest("a")?.getAttribute("href");
|
||||
expect(nextHref).toContain("page=3");
|
||||
expect(nextHref).toContain("search=foo");
|
||||
expect(nextHref).toContain("tag=a");
|
||||
expect(nextHref).toContain("tag=b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { RouteTypeBadge } from "@/components/RouteTypeBadge";
|
||||
|
||||
describe("RouteTypeBadge", () => {
|
||||
it("renders nothing for null", () => {
|
||||
const { container } = render(<RouteTypeBadge routeType={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Flood badge for flood", () => {
|
||||
render(<RouteTypeBadge routeType="flood" />);
|
||||
expect(screen.getByText("Flood")).toHaveClass("badge-info");
|
||||
});
|
||||
|
||||
it("renders Relay badge for transport_flood", () => {
|
||||
render(<RouteTypeBadge routeType="transport_flood" />);
|
||||
expect(screen.getByText("Relay")).toHaveClass("badge-info");
|
||||
});
|
||||
|
||||
it("renders Zero-hop for direct", () => {
|
||||
render(<RouteTypeBadge routeType="direct" />);
|
||||
expect(screen.getByText("Zero-hop")).toHaveClass("badge-success");
|
||||
});
|
||||
|
||||
it("renders Direct relay for transport_direct", () => {
|
||||
render(<RouteTypeBadge routeType="transport_direct" />);
|
||||
expect(screen.getByText("Direct relay")).toHaveClass("badge-success");
|
||||
});
|
||||
|
||||
it("renders nothing for an unknown type", () => {
|
||||
const { container } = render(<RouteTypeBadge routeType="weird" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SortableTableHeader, MobileSortSelect } from "@/components/SortableTable";
|
||||
|
||||
function renderWithRouter(ui: React.ReactElement) {
|
||||
return render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
}
|
||||
|
||||
function renderInTable(ui: React.ReactElement) {
|
||||
return renderWithRouter(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{ui}</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SortableTableHeader", () => {
|
||||
it("links to asc when the column is not currently sorted", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableTableHeader
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSort="date"
|
||||
currentOrder="desc"
|
||||
basePath="/nodes"
|
||||
/>,
|
||||
);
|
||||
const href = container.querySelector("a")?.getAttribute("href") ?? "";
|
||||
expect(href).toContain("sort=name");
|
||||
expect(href).toContain("order=asc");
|
||||
});
|
||||
|
||||
it("flips asc to desc with the up indicator", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableTableHeader
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSort="name"
|
||||
currentOrder="asc"
|
||||
basePath="/nodes"
|
||||
/>,
|
||||
);
|
||||
const link = container.querySelector("a");
|
||||
expect(link?.getAttribute("href")).toContain("order=desc");
|
||||
expect(link?.textContent).toContain("▴");
|
||||
});
|
||||
|
||||
it("flips desc back to asc with the down indicator", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableTableHeader
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSort="name"
|
||||
currentOrder="desc"
|
||||
basePath="/nodes"
|
||||
/>,
|
||||
);
|
||||
const link = container.querySelector("a");
|
||||
expect(link?.getAttribute("href")).toContain("order=asc");
|
||||
expect(link?.textContent).toContain("▾");
|
||||
});
|
||||
|
||||
it("preserves existing params in the generated sort URL", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableTableHeader
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSort="date"
|
||||
currentOrder="asc"
|
||||
basePath="/nodes"
|
||||
params={{ search: "foo", tag: ["a", "b"] }}
|
||||
/>,
|
||||
);
|
||||
const href = container.querySelector("a")?.getAttribute("href") ?? "";
|
||||
expect(href).toContain("search=foo");
|
||||
expect(href).toContain("tag=a");
|
||||
expect(href).toContain("tag=b");
|
||||
});
|
||||
|
||||
it("stops propagation on header link click", () => {
|
||||
const parentClick = vi.fn();
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<table>
|
||||
<thead>
|
||||
<tr onClick={parentClick}>
|
||||
<SortableTableHeader
|
||||
label="X"
|
||||
sortKey="x"
|
||||
currentSort=""
|
||||
currentOrder=""
|
||||
basePath="/"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fireEvent.click(container.querySelector("a")!);
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MobileSortSelect", () => {
|
||||
it("renders options with the current value selected", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MobileSortSelect
|
||||
currentSort="name"
|
||||
currentOrder="asc"
|
||||
basePath="/nodes"
|
||||
options={[
|
||||
{ value: "name:asc", label: "Name asc" },
|
||||
{ value: "name:desc", label: "Name desc" },
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByRole("combobox")).toHaveValue("name:asc");
|
||||
expect(screen.getByText("Name desc")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
|
||||
describe("StatCard", () => {
|
||||
it("renders title, formatted value, and description", () => {
|
||||
render(
|
||||
<StatCard
|
||||
icon="star"
|
||||
color="#f00"
|
||||
title="Nodes"
|
||||
value={1234}
|
||||
description="active"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Nodes")).toHaveClass("stat-title");
|
||||
expect(screen.getByText("1,234")).toHaveClass("stat-value");
|
||||
expect(screen.getByText("active")).toHaveClass("stat-desc");
|
||||
});
|
||||
|
||||
it("omits description when not provided", () => {
|
||||
const { container } = render(
|
||||
<StatCard icon="star" color="#f00" title="X" value={5} />,
|
||||
);
|
||||
expect(container.querySelector(".stat-desc")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies the panel color as a CSS variable", () => {
|
||||
const { container } = render(
|
||||
<StatCard icon="star" color="#abc" title="X" value={1} />,
|
||||
);
|
||||
const panel = container.querySelector(".stat") as HTMLElement;
|
||||
expect(panel.style.getPropertyValue("--panel-color")).toBe("#abc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
|
||||
describe("ThemeToggle", () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("initializes unchecked when no data-theme attribute is set", () => {
|
||||
render(<ThemeToggle />);
|
||||
expect(screen.getByTestId("theme-toggle")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("toggling sets data-theme to light and persists to localStorage", () => {
|
||||
render(<ThemeToggle />);
|
||||
const checkbox = screen.getByTestId("theme-toggle");
|
||||
fireEvent.click(checkbox);
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
expect(localStorage.getItem("meshcore-theme")).toBe("light");
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("toggling back switches to dark", () => {
|
||||
render(<ThemeToggle />);
|
||||
const checkbox = screen.getByTestId("theme-toggle");
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(checkbox);
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
expect(localStorage.getItem("meshcore-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("renders both sun and moon svg icons", () => {
|
||||
const { container } = render(<ThemeToggle />);
|
||||
expect(container.querySelectorAll("svg").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-chartjs-2", () => ({
|
||||
Line: () => <div data-testid="mock-line-chart" />,
|
||||
Bar: () => <div data-testid="mock-bar-chart" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/charts", () => ({
|
||||
buildActivityChart: (a: unknown, m: unknown) =>
|
||||
a != null || m != null ? { data: {}, options: {} } : null,
|
||||
buildLineChart: (d: unknown) => (d != null ? { data: {}, options: {} } : null),
|
||||
buildStackedBar: (b: unknown) => (b != null ? { data: {}, options: {} } : null),
|
||||
buildRoutesTrend: (r: unknown) => (r != null ? { data: {}, options: {} } : null),
|
||||
buildRouteDetailStrip: (d: unknown) =>
|
||||
d != null ? { data: {}, options: {} } : null,
|
||||
}));
|
||||
|
||||
import {
|
||||
ActivityChart,
|
||||
TrendLineChart,
|
||||
StackedBarChart,
|
||||
RoutesTrendChart,
|
||||
RouteDetailStrip,
|
||||
} from "@/components/charts/Charts";
|
||||
|
||||
// Since @/utils/charts is mocked, the actual data shape is irrelevant —
|
||||
// these casts just satisfy the component prop types at compile time.
|
||||
const DATA = { data: [] } as never;
|
||||
|
||||
describe("Chart wrappers", () => {
|
||||
it("ActivityChart renders a Line when data is present", () => {
|
||||
render(<ActivityChart advertData={DATA} messageData={null} />);
|
||||
expect(screen.getByTestId("mock-line-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ActivityChart renders nothing when both series are null", () => {
|
||||
const { container } = render(
|
||||
<ActivityChart advertData={null} messageData={null} />,
|
||||
);
|
||||
expect(container.querySelector('[data-testid="mock-line-chart"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("TrendLineChart renders a Line when data is provided", () => {
|
||||
render(
|
||||
<TrendLineChart
|
||||
data={DATA}
|
||||
label="x"
|
||||
borderColor="#f00"
|
||||
backgroundColor="#0f0"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("mock-line-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("StackedBarChart renders a Bar when buckets are provided", () => {
|
||||
render(<StackedBarChart buckets={DATA} colors={["#f00"]} />);
|
||||
expect(screen.getByTestId("mock-bar-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("RoutesTrendChart renders a Line when routes are provided", () => {
|
||||
render(<RoutesTrendChart routes={DATA} />);
|
||||
expect(screen.getByTestId("mock-line-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("RouteDetailStrip renders a Bar when data is provided", () => {
|
||||
render(<RouteDetailStrip data={DATA} />);
|
||||
expect(screen.getByTestId("mock-bar-chart")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Advertisements } from "@/pages/Advertisements";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const ADVERTS = {
|
||||
items: [
|
||||
{
|
||||
public_key: "c".repeat(64),
|
||||
name: "AdNode",
|
||||
adv_type: "repeater",
|
||||
route_type: "flood",
|
||||
first_seen: "2024-01-01T00:00:00Z",
|
||||
last_seen: "2024-01-01T12:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
};
|
||||
|
||||
function mockAdvertsApi() {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/advertisements")) return ADVERTS;
|
||||
if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Advertisements", () => {
|
||||
it("shows a loading spinner before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Advertisements />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders advertisement rows after data resolves", async () => {
|
||||
mockAdvertsApi();
|
||||
renderWithProviders(<Advertisements />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("AdNode").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error alert on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("timeout"));
|
||||
const { container } = renderWithProviders(<Advertisements />);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-tip="timeout"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders an empty state when no adverts exist", async () => {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/advertisements")) return { items: [], total: 0 };
|
||||
if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
renderWithProviders(<Advertisements />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("AdNode")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Channels } from "@/pages/Channels";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const CHANNELS = {
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Public",
|
||||
channel_hash: "11",
|
||||
visibility: "community",
|
||||
enabled: true,
|
||||
masked_key: "***",
|
||||
key_hex: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Ops",
|
||||
channel_hash: "22",
|
||||
visibility: "operator",
|
||||
enabled: true,
|
||||
masked_key: "***",
|
||||
key_hex: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
|
||||
function mockChannelsApi() {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue(CHANNELS);
|
||||
}
|
||||
|
||||
describe("Channels", () => {
|
||||
it("shows a loading spinner before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Channels />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders channel cards after data resolves", async () => {
|
||||
mockChannelsApi();
|
||||
renderWithProviders(<Channels />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Public")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ops")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error alert on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("channels down"));
|
||||
renderWithProviders(<Channels />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("channels down");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders an empty state when no channels exist", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue({ items: [], total: 0 });
|
||||
renderWithProviders(<Channels />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Public")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
|
||||
import { CustomPagePage } from "@/pages/CustomPage";
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const originalT = window.t;
|
||||
|
||||
function renderPage(entry = "/pages/about") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<AppConfigProvider config={makeConfig({ network_name: "TestNet" })}>
|
||||
<Routes>
|
||||
<Route path="/pages/:slug" element={<CustomPagePage />} />
|
||||
</Routes>
|
||||
</AppConfigProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.t = (key: string) => key;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.t = originalT;
|
||||
});
|
||||
|
||||
describe("CustomPage", () => {
|
||||
it("shows a loading spinner before the fetch resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
const { container } = renderPage();
|
||||
expect(container.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders markdown content after the fetch resolves", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue({
|
||||
slug: "about",
|
||||
title: "About",
|
||||
content_markdown: "# Hello World",
|
||||
});
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Hello World")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows page-not-found on a 404 response", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(
|
||||
new Error("API error: 404 Not Found"),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toHaveTextContent(/page_not_found/i);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a generic error on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("boom"));
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toHaveTextContent("boom");
|
||||
});
|
||||
});
|
||||
|
||||
it("sets document.title from the page title and network name", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue({
|
||||
slug: "about",
|
||||
title: "About",
|
||||
content_markdown: "",
|
||||
});
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("About - TestNet");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useLocation, useParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ErrorAlert, Loading } from "@/components/Alerts";
|
||||
import { Breadcrumbs } from "@/components/Breadcrumbs";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { apiGet, isAbortError } from "@/utils/api";
|
||||
|
||||
interface CustomPageData {
|
||||
slug: string;
|
||||
title: string;
|
||||
content_html: string;
|
||||
content_markdown: string;
|
||||
}
|
||||
|
||||
export function CustomPagePage() {
|
||||
const { slug = "" } = useParams();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const config = useAppConfig();
|
||||
|
||||
@@ -54,6 +56,14 @@ export function CustomPagePage() {
|
||||
document.title = `${page.title} - ${networkName}`;
|
||||
}, [page, config.network_name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!page || !location.hash) return;
|
||||
const id = location.hash.slice(1);
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
}, [page, location.hash]);
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (error) return <ErrorAlert message={error} />;
|
||||
if (!page) return null;
|
||||
@@ -64,10 +74,9 @@ export function CustomPagePage() {
|
||||
items={[{ label: t("entities.home"), to: "/" }, { label: page.title }]}
|
||||
/>
|
||||
<div className="card bg-base-100 shadow-xl">
|
||||
<div
|
||||
className="card-body prose prose-lg max-w-none overflow-x-auto"
|
||||
dangerouslySetInnerHTML={{ __html: page.content_html }}
|
||||
/>
|
||||
<div className="card-body overflow-x-auto">
|
||||
<Markdown>{page.content_markdown}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/charts/Charts", () => ({
|
||||
ActivityChart: () => null,
|
||||
TrendLineChart: () => null,
|
||||
StackedBarChart: () => null,
|
||||
RoutesTrendChart: () => null,
|
||||
RouteDetailStrip: () => null,
|
||||
}));
|
||||
|
||||
import { DashboardPage as Dashboard } from "@/pages/Dashboard";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const STATS = {
|
||||
node_count: 10,
|
||||
message_count: 50,
|
||||
packet_count: 200,
|
||||
channel_count: 3,
|
||||
observer_count: 4,
|
||||
route_count: 1,
|
||||
};
|
||||
|
||||
function mockDashboardApi() {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/dashboard/stats")) return STATS;
|
||||
if (path.includes("/dashboard/")) return { data: [] };
|
||||
if (path.includes("/api/v1/channels")) return { items: [] };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Dashboard", () => {
|
||||
it("shows a loading state before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Dashboard />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders dashboard content after data resolves", async () => {
|
||||
mockDashboardApi();
|
||||
renderWithProviders(<Dashboard />);
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".loading-spinner")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("dash error"));
|
||||
renderWithProviders(<Dashboard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("dash error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/charts/Charts", () => ({
|
||||
ActivityChart: () => null,
|
||||
TrendLineChart: () => null,
|
||||
StackedBarChart: () => null,
|
||||
RoutesTrendChart: () => null,
|
||||
RouteDetailStrip: () => null,
|
||||
}));
|
||||
|
||||
import { HomePage as Home } from "@/pages/Home";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const STATS = {
|
||||
node_count: 42,
|
||||
message_count: 100,
|
||||
packet_count: 500,
|
||||
channel_count: 3,
|
||||
observer_count: 5,
|
||||
route_count: 2,
|
||||
};
|
||||
|
||||
function mockHomeApi() {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/dashboard/stats")) return STATS;
|
||||
if (path.includes("/dashboard/activity")) return { data: [] };
|
||||
if (path.includes("/dashboard/message-activity")) return { data: [] };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Home", () => {
|
||||
it("shows a loading state before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Home />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders stat cards after data resolves", async () => {
|
||||
mockHomeApi();
|
||||
renderWithProviders(<Home />);
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".loading-spinner")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders without error when all features are disabled", async () => {
|
||||
mockHomeApi();
|
||||
renderWithProviders(<Home />, {
|
||||
config: makeConfig({ features: { dashboard: false, nodes: false, map: false } }),
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import { Maintenance } from "@/pages/Maintenance";
|
||||
|
||||
describe("Maintenance", () => {
|
||||
it("renders the maintenance hero with title and description", () => {
|
||||
renderWithProviders(<Maintenance />);
|
||||
expect(screen.getByText("🔧")).toBeInTheDocument();
|
||||
expect(screen.getByText("maintenance.title")).toBeInTheDocument();
|
||||
expect(screen.getByText("maintenance.description")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-leaflet", () => ({
|
||||
MapContainer: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="mock-map">{children}</div>
|
||||
),
|
||||
TileLayer: () => null,
|
||||
Marker: () => null,
|
||||
Popup: () => null,
|
||||
useMap: () => ({ fitBounds: () => {}, latLngToContainerPoint: () => ({ x: 0, y: 0 }) }),
|
||||
}));
|
||||
|
||||
vi.mock("leaflet", () => ({
|
||||
divIcon: () => ({}),
|
||||
latLngBounds: () => ({}),
|
||||
point: () => ({}),
|
||||
}));
|
||||
|
||||
import { MapPage } from "@/pages/MapPage";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const MAP_DATA = {
|
||||
nodes: [
|
||||
{
|
||||
public_key: "a".repeat(64),
|
||||
name: "MapNode",
|
||||
adv_type: "chat",
|
||||
lat: 40.7,
|
||||
lon: -74.0,
|
||||
last_seen: "2024-01-01T00:00:00Z",
|
||||
is_adopted: false,
|
||||
role: null,
|
||||
owner: null,
|
||||
},
|
||||
],
|
||||
center: null,
|
||||
adopted_center: null,
|
||||
debug: { total_nodes: 1, nodes_with_coords: 1, error: null },
|
||||
profiles: [],
|
||||
};
|
||||
|
||||
describe("MapPage", () => {
|
||||
it("shows a loading state before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<MapPage />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the map after data resolves", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue(MAP_DATA);
|
||||
renderWithProviders(<MapPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mock-map")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("map error"));
|
||||
const { container } = renderWithProviders(<MapPage />);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".alert-error")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,17 +16,19 @@ import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { apiGet } from "@/utils/api";
|
||||
import { qk } from "@/utils/queryKeys";
|
||||
import { formatNumber, formatRelativeTime, typeEmoji } from "@/utils/format";
|
||||
import {
|
||||
getDistanceKm,
|
||||
getNodesWithinRadius,
|
||||
getAnchorPoint,
|
||||
normalizeType,
|
||||
type LatLng,
|
||||
} from "@/utils/mapMath";
|
||||
import { FilterToggle, OperatorSelect } from "@/components/FilterForm";
|
||||
import { ErrorAlert, Loading } from "@/components/Alerts";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
const MAX_BOUNDS_RADIUS_KM = 20;
|
||||
|
||||
interface LatLng {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
interface MapNodeOwner {
|
||||
name: string;
|
||||
callsign: string | null;
|
||||
@@ -72,55 +74,12 @@ function escapeHtml(str: string | null | undefined): string {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function getDistanceKm(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number,
|
||||
): number {
|
||||
const R = 6371;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
function getNodesWithinRadius(
|
||||
nodes: MapNode[],
|
||||
anchorLat: number,
|
||||
anchorLon: number,
|
||||
radiusKm: number,
|
||||
): MapNode[] {
|
||||
return nodes.filter(
|
||||
(n) => getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm,
|
||||
);
|
||||
}
|
||||
|
||||
function getAnchorPoint(nodes: MapNode[], adoptedCenter: LatLng | null): LatLng {
|
||||
if (adoptedCenter) return adoptedCenter;
|
||||
if (nodes.length === 0) return { lat: 0, lon: 0 };
|
||||
return {
|
||||
lat: nodes.reduce((sum, n) => sum + n.lat, 0) / nodes.length,
|
||||
lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
function getBoundsPadding(): [number, number] {
|
||||
if (window.innerWidth < 480) return [50, 50];
|
||||
if (window.innerWidth < 768) return [75, 75];
|
||||
return [100, 100];
|
||||
}
|
||||
|
||||
function normalizeType(type: string | null): string | null {
|
||||
return type ? type.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function getTypeDisplay(node: MapNode, t: TFunction): string {
|
||||
const type = normalizeType(node.adv_type);
|
||||
if (type === "chat") return t("node_types.chat");
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Members } from "@/pages/Members";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const PROFILES = {
|
||||
items: [
|
||||
{ id: "1", name: "Alice", roles: ["operator"], callsign: "AB1" },
|
||||
{ id: "2", name: "Bob", roles: ["member"] },
|
||||
{ id: "3", name: "TestUser", roles: ["test"] },
|
||||
],
|
||||
};
|
||||
|
||||
function mockProfiles(items = PROFILES) {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue(items);
|
||||
}
|
||||
|
||||
describe("Members", () => {
|
||||
it("shows a loading spinner before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Members />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders operators and members excluding test profiles", async () => {
|
||||
mockProfiles();
|
||||
renderWithProviders(<Members />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
expect(screen.queryByText("TestUser")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when no visible profiles exist", async () => {
|
||||
mockProfiles({
|
||||
items: [{ id: "9", name: "Hidden", roles: ["test"] }],
|
||||
});
|
||||
renderWithProviders(<Members />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("members_page.empty_state")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error alert on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("fetch failed"));
|
||||
renderWithProviders(<Members />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("fetch failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Messages } from "@/pages/Messages";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const MESSAGES = {
|
||||
items: [
|
||||
{
|
||||
message_type: "channel",
|
||||
text: "Hello world",
|
||||
channel_idx: 17,
|
||||
received_at: "2024-01-01T00:00:00Z",
|
||||
signature: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
};
|
||||
|
||||
function mockMessagesApi() {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/messages")) return MESSAGES;
|
||||
if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
|
||||
if (path.includes("/api/v1/channels")) return { items: [] };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Messages", () => {
|
||||
it("shows a loading spinner before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Messages />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders messages after data resolves", async () => {
|
||||
mockMessagesApi();
|
||||
renderWithProviders(<Messages />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Hello world").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error alert on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("disconnected"));
|
||||
const { container } = renderWithProviders(<Messages />);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-tip="disconnected"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders an empty state when no messages exist", async () => {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/messages")) return { items: [], total: 0 };
|
||||
if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
|
||||
if (path.includes("/api/v1/channels")) return { items: [] };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
renderWithProviders(<Messages />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Hello world")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,18 @@ import { useNavigate, useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getChannelLabelsMap,
|
||||
resolveChannelLabel,
|
||||
useAppConfig,
|
||||
} from "@/context/AppConfigContext";
|
||||
import { apiGet } from "@/utils/api";
|
||||
import { qk } from "@/utils/queryKeys";
|
||||
import { useFormatDateTime } from "@/utils/format";
|
||||
import {
|
||||
parseSenderFromText,
|
||||
collapseNewlines,
|
||||
channelInfo,
|
||||
messageTextWithSender,
|
||||
dedupeBySignature,
|
||||
} from "@/utils/messageHelpers";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
@@ -73,156 +79,6 @@ interface ListResponse<T> {
|
||||
total?: number;
|
||||
}
|
||||
|
||||
function parseSenderFromText(text: string | null): {
|
||||
sender: string | null;
|
||||
text: string;
|
||||
} {
|
||||
if (!text || typeof text !== "string") {
|
||||
return { sender: null, text: text || "-" };
|
||||
}
|
||||
const patterns = [
|
||||
/^\s*ack\s+@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
|
||||
/^\s*@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
|
||||
/^\s*ack\s+([^:|\n]{1,80})\s*:\s*([\s\S]+)$/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (!match) continue;
|
||||
const sender = (match[1] || "").trim();
|
||||
const remaining = (match[2] || "").trim();
|
||||
if (!sender) continue;
|
||||
return { sender, text: remaining || text };
|
||||
}
|
||||
return { sender: null, text };
|
||||
}
|
||||
|
||||
function collapseNewlines(text: string | null): string | null {
|
||||
if (!text || typeof text !== "string") return text;
|
||||
return text.replace(/\s*\n\s*/g, " ");
|
||||
}
|
||||
|
||||
function channelInfo(
|
||||
msg: Message,
|
||||
channelLabels: Map<number, string>,
|
||||
fallbackLabel: string,
|
||||
): { label: string | null; text: string } {
|
||||
if (msg.message_type !== "channel") {
|
||||
return { label: null, text: msg.text || "-" };
|
||||
}
|
||||
const rawText = msg.text || "";
|
||||
const match = rawText.match(/^\[([^\]]+)\]\s+([\s\S]*)$/);
|
||||
if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
|
||||
const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
|
||||
if (knownLabel) {
|
||||
return {
|
||||
label: knownLabel,
|
||||
text: match ? match[2] || "-" : rawText || "-",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (msg.channel_name) {
|
||||
return { label: msg.channel_name, text: msg.text || "-" };
|
||||
}
|
||||
if (match) {
|
||||
return { label: match[1], text: match[2] || "-" };
|
||||
}
|
||||
if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
|
||||
const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
|
||||
return { label: knownLabel || `Ch ${msg.channel_idx}`, text: rawText || "-" };
|
||||
}
|
||||
return { label: fallbackLabel, text: rawText || "-" };
|
||||
}
|
||||
|
||||
function messageTextWithSender(msg: Message, text: string): string {
|
||||
const parsed = parseSenderFromText(text || "-");
|
||||
const explicitSender =
|
||||
msg.sender_tag_name ||
|
||||
msg.sender_name ||
|
||||
(msg.pubkey_prefix || "").slice(0, 12) ||
|
||||
null;
|
||||
const sender = explicitSender || parsed.sender;
|
||||
const body = collapseNewlines((parsed.text || text || "-").trim()) || "-";
|
||||
if (!sender) return body;
|
||||
if (body.toLowerCase().startsWith(`${sender.toLowerCase()}:`)) return body;
|
||||
return `${sender}: ${body}`;
|
||||
}
|
||||
|
||||
function dedupeBySignature(items: Message[]): Message[] {
|
||||
const deduped: Message[] = [];
|
||||
const bySignature = new Map<string, Message>();
|
||||
|
||||
for (const msg of items) {
|
||||
const signature =
|
||||
typeof msg.signature === "string"
|
||||
? msg.signature.trim().toUpperCase()
|
||||
: "";
|
||||
const canDedupe = msg.message_type === "channel" && signature.length >= 8;
|
||||
if (!canDedupe) {
|
||||
deduped.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = bySignature.get(signature);
|
||||
if (!existing) {
|
||||
const clone: Message = {
|
||||
...msg,
|
||||
observers: [...(msg.observers ?? [])],
|
||||
};
|
||||
bySignature.set(signature, clone);
|
||||
deduped.push(clone);
|
||||
continue;
|
||||
}
|
||||
|
||||
const combined = [...(existing.observers ?? []), ...(msg.observers ?? [])];
|
||||
const seenReceivers = new Set<string>();
|
||||
existing.observers = combined.filter((recv) => {
|
||||
const key =
|
||||
recv?.public_key ||
|
||||
recv?.node_id ||
|
||||
`${recv?.observed_at ?? ""}:${recv?.snr ?? ""}`;
|
||||
if (seenReceivers.has(key)) return false;
|
||||
seenReceivers.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!existing.observed_by && msg.observed_by)
|
||||
existing.observed_by = msg.observed_by;
|
||||
if (!existing.observer_name && msg.observer_name)
|
||||
existing.observer_name = msg.observer_name;
|
||||
if (!existing.observer_tag_name && msg.observer_tag_name)
|
||||
existing.observer_tag_name = msg.observer_tag_name;
|
||||
if (!existing.pubkey_prefix && msg.pubkey_prefix)
|
||||
existing.pubkey_prefix = msg.pubkey_prefix;
|
||||
if (!existing.sender_name && msg.sender_name)
|
||||
existing.sender_name = msg.sender_name;
|
||||
if (!existing.sender_tag_name && msg.sender_tag_name)
|
||||
existing.sender_tag_name = msg.sender_tag_name;
|
||||
if (!existing.channel_name && msg.channel_name)
|
||||
existing.channel_name = msg.channel_name;
|
||||
if (
|
||||
existing.channel_name === "Public" &&
|
||||
msg.channel_name &&
|
||||
msg.channel_name !== "Public"
|
||||
) {
|
||||
existing.channel_name = msg.channel_name;
|
||||
}
|
||||
if (existing.channel_idx === null || existing.channel_idx === undefined) {
|
||||
if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
|
||||
existing.channel_idx = msg.channel_idx;
|
||||
}
|
||||
} else if (
|
||||
existing.channel_idx === 17 &&
|
||||
msg.channel_idx !== null &&
|
||||
msg.channel_idx !== undefined &&
|
||||
msg.channel_idx !== 17
|
||||
) {
|
||||
existing.channel_idx = msg.channel_idx;
|
||||
}
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function Messages() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-leaflet", () => ({
|
||||
MapContainer: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="mock-map">{children}</div>
|
||||
),
|
||||
TileLayer: () => null,
|
||||
Marker: () => null,
|
||||
Popup: () => null,
|
||||
useMap: () => ({ fitBounds: () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock("leaflet", () => ({
|
||||
divIcon: () => ({}),
|
||||
latLngBounds: () => ({}),
|
||||
point: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MeshQrCode", () => ({
|
||||
MeshQrCode: () => <div data-testid="mock-qr" />,
|
||||
}));
|
||||
|
||||
import { NodeDetailPage as NodeDetail } from "@/pages/NodeDetail";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const KEY = "a".repeat(64);
|
||||
const NODE = {
|
||||
public_key: KEY,
|
||||
name: "DetailNode",
|
||||
adv_type: "chat",
|
||||
last_seen: "2024-01-01T00:00:00Z",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
function mockNodeDetailApi() {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes(`/api/v1/nodes/${KEY}`)) return NODE;
|
||||
if (path.includes("/api/v1/advertisements")) return { items: [], total: 0 };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("NodeDetail", () => {
|
||||
it("shows a loading state before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<NodeDetail />, {
|
||||
route: `/nodes/${KEY}`,
|
||||
routePath: "/nodes/:publicKey",
|
||||
});
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders node detail after data resolves", async () => {
|
||||
mockNodeDetailApi();
|
||||
renderWithProviders(<NodeDetail />, {
|
||||
route: `/nodes/${KEY}`,
|
||||
routePath: "/nodes/:publicKey",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".loading-spinner")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("node error"));
|
||||
renderWithProviders(<NodeDetail />, {
|
||||
route: `/nodes/${KEY}`,
|
||||
routePath: "/nodes/:publicKey",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("node error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Nodes } from "@/pages/Nodes";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const KEY = "a".repeat(64);
|
||||
const NODES = {
|
||||
items: [
|
||||
{
|
||||
public_key: KEY,
|
||||
name: "TestNode",
|
||||
adv_type: "chat",
|
||||
last_seen: "2024-01-01T00:00:00Z",
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
function mockNodesApi() {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/nodes")) return NODES;
|
||||
if (path.includes("/api/v1/user/profiles")) return { items: [] };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Nodes", () => {
|
||||
it("shows a loading spinner before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Nodes />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders node rows after data resolves", async () => {
|
||||
mockNodesApi();
|
||||
renderWithProviders(<Nodes />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("TestNode").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error alert on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("server down"));
|
||||
const { container } = renderWithProviders(<Nodes />);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-tip="server down"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders an empty state when no nodes exist", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
renderWithProviders(<Nodes />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("TestNode")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders without error when OIDC is enabled", async () => {
|
||||
mockNodesApi();
|
||||
renderWithProviders(<Nodes />, {
|
||||
config: makeConfig({ oidc_enabled: true, user: { sub: "u1", name: "Admin" } }),
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("TestNode").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import { NotFound } from "@/pages/NotFound";
|
||||
|
||||
describe("NotFound", () => {
|
||||
it("renders the 404 hero", () => {
|
||||
renderWithProviders(<NotFound />);
|
||||
expect(screen.getByText("404")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.page_not_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has links to home and nodes", () => {
|
||||
renderWithProviders(<NotFound />);
|
||||
const homeLink = screen.getByText("common.go_home").closest("a");
|
||||
expect(homeLink).toHaveAttribute("href", "/");
|
||||
const nodesLink = screen
|
||||
.getByText(/common.view_entity/)
|
||||
.closest("a");
|
||||
expect(nodesLink).toHaveAttribute("href", "/nodes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PacketDetail } from "@/pages/PacketDetail";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const PACKET = {
|
||||
packet_hash: "abc123",
|
||||
event_type: "advert",
|
||||
channel_idx: 17,
|
||||
observed_by: "nodekey1",
|
||||
observer_name: "Observer1",
|
||||
observer_tag_name: null,
|
||||
source_pubkey_prefix: "deadbeef",
|
||||
packet_type: 1,
|
||||
payload_type: 2,
|
||||
route_type: "direct",
|
||||
snr: -5.5,
|
||||
path_len: 3,
|
||||
received_at: "2024-01-01T00:00:00Z",
|
||||
redacted: false,
|
||||
raw_hex: "deadbeef",
|
||||
decoded: { foo: "bar" },
|
||||
};
|
||||
|
||||
function mockPacketApi(packet?: unknown, error?: Error) {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/channels")) return { items: [] };
|
||||
if (error) throw error;
|
||||
return packet ?? PACKET;
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return renderWithProviders(<PacketDetail />, {
|
||||
route: "/packets/test-id",
|
||||
routePath: "/packets/:id",
|
||||
});
|
||||
}
|
||||
|
||||
describe("PacketDetail", () => {
|
||||
it("shows a loading spinner before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderPage();
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders packet fields after data resolves", async () => {
|
||||
mockPacketApi();
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("abc123").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(screen.getByText("Observer1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows not-found state on a 404 error", async () => {
|
||||
const err = new Error("API error: 404 Not Found");
|
||||
mockPacketApi(undefined, err);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/entity_not_found/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a warning badge on non-404 errors", async () => {
|
||||
mockPacketApi(undefined, new Error("boom"));
|
||||
const { container } = renderPage();
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-tip="boom"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PacketGroupDetail } from "@/pages/PacketGroupDetail";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const GROUP = {
|
||||
packet_hash: "grouphash",
|
||||
event_type: "advert",
|
||||
channel_idx: 17,
|
||||
first_seen: "2024-01-01T00:00:00Z",
|
||||
redacted: false,
|
||||
raw_hex: "deadbeef",
|
||||
decoded: { type: "test" },
|
||||
receptions: [
|
||||
{
|
||||
packet_id: "p1",
|
||||
observed_by: "obs1",
|
||||
observer_name: "Observer1",
|
||||
snr: -5.0,
|
||||
observed_at: "2024-01-01T00:00:00Z",
|
||||
path: ["a", "b"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function mockGroupApi() {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/packet-groups/")) return GROUP;
|
||||
if (path.includes("/api/v1/channels")) return { items: [] };
|
||||
if (path.includes("/api/v1/nodes")) return { items: [], total: 0 };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("PacketGroupDetail", () => {
|
||||
it("shows a loading state before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<PacketGroupDetail />, {
|
||||
route: "/packets/hash/abc",
|
||||
routePath: "/packets/hash/:hash",
|
||||
});
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders group detail after data resolves", async () => {
|
||||
mockGroupApi();
|
||||
renderWithProviders(<PacketGroupDetail />, {
|
||||
route: "/packets/hash/abc",
|
||||
routePath: "/packets/hash/:hash",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("grouphash").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/channels")) return { items: [] };
|
||||
throw new Error("group fetch failed");
|
||||
});
|
||||
const { container } = renderWithProviders(<PacketGroupDetail />, {
|
||||
route: "/packets/hash/abc",
|
||||
routePath: "/packets/hash/:hash",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelector('[data-tip="group fetch failed"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
truncateKey,
|
||||
useFormatDateTime,
|
||||
} from "@/utils/format";
|
||||
import { groupByObserver } from "@/utils/packetGroupHelpers";
|
||||
import { Loading, WarningBadge } from "@/components/Alerts";
|
||||
import { Breadcrumbs } from "@/components/Breadcrumbs";
|
||||
import { IconSatelliteDish } from "@/components/icons";
|
||||
@@ -92,20 +93,6 @@ interface PopoverAnchor {
|
||||
top: number;
|
||||
}
|
||||
|
||||
function groupByObserver(receptions: Reception[]): Map<string, Reception[]> {
|
||||
const groups = new Map<string, Reception[]>();
|
||||
for (const r of receptions) {
|
||||
const key = r.observed_by || "__unknown__";
|
||||
const list = groups.get(key);
|
||||
if (list) {
|
||||
list.push(r);
|
||||
} else {
|
||||
groups.set(key, [r]);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Packets } from "@/pages/Packets";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const GROUPS = {
|
||||
items: [
|
||||
{
|
||||
packet_hash: "hash1",
|
||||
event_type: "advert",
|
||||
channel_idx: 17,
|
||||
path_hash_bytes: null,
|
||||
reception_count: 3,
|
||||
observer_count: 2,
|
||||
first_seen: "2024-01-01T00:00:00Z",
|
||||
redacted: false,
|
||||
receptions: [{ packet_id: "p1" }],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
};
|
||||
|
||||
function mockPacketsApi() {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/packet-groups")) return GROUPS;
|
||||
if (path.includes("/api/v1/channels")) return { items: [] };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Packets", () => {
|
||||
it("shows a loading spinner before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Packets />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders packet group rows after data resolves", async () => {
|
||||
mockPacketsApi();
|
||||
renderWithProviders(<Packets />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("hash1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error alert on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("network error"));
|
||||
const { container } = renderWithProviders(<Packets />);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-tip="network error"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders an empty state when no packets exist", async () => {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path.includes("/api/v1/packet-groups")) return { items: [], total: 0 };
|
||||
if (path.includes("/api/v1/channels")) return { items: [] };
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
renderWithProviders(<Packets />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("hash1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,11 @@ import { useAutoRefresh } from "@/hooks/useAutoRefresh";
|
||||
import { apiGet } from "@/utils/api";
|
||||
import { qk } from "@/utils/queryKeys";
|
||||
import { formatNumber, useFormatDateTime } from "@/utils/format";
|
||||
import {
|
||||
buildChannelList,
|
||||
packetUrl,
|
||||
type ChannelEntry,
|
||||
} from "@/utils/packetHelpers";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { FilterForm, FilterField } from "@/components/FilterForm";
|
||||
import {
|
||||
@@ -70,24 +75,6 @@ interface ChannelsResponse {
|
||||
items: ChannelItem[];
|
||||
}
|
||||
|
||||
interface ChannelEntry {
|
||||
idx: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function buildChannelList(items: ChannelItem[]): ChannelEntry[] {
|
||||
return items
|
||||
.map((c) => ({ name: c.name, idx: parseInt(c.channel_hash, 16) }))
|
||||
.filter((c) => !Number.isNaN(c.idx));
|
||||
}
|
||||
|
||||
function packetUrl(p: PacketGroupItem): string {
|
||||
if (p.packet_hash) return `/packets/hash/${p.packet_hash}`;
|
||||
if (p.receptions && p.receptions.length > 0)
|
||||
return `/packets/${p.receptions[0].packet_id}`;
|
||||
return "/packets";
|
||||
}
|
||||
|
||||
function ChannelLabel({
|
||||
packet,
|
||||
channelNames,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Profile } from "@/pages/Profile";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const PROFILE_DATA = {
|
||||
id: "p1",
|
||||
user_id: "user-123",
|
||||
name: "Jane Operator",
|
||||
callsign: "AB1CDE",
|
||||
description: "Mesh enthusiast",
|
||||
url: "https://example.com",
|
||||
roles: ["operator"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
nodes: [],
|
||||
};
|
||||
|
||||
describe("Profile (public view)", () => {
|
||||
it("shows a loading spinner before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Profile />, {
|
||||
route: "/profile/p1",
|
||||
routePath: "/profile/:id",
|
||||
});
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders profile fields after data resolves", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
|
||||
renderWithProviders(<Profile />, {
|
||||
route: "/profile/p1",
|
||||
routePath: "/profile/:id",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(screen.getAllByText("AB1CDE").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows an error alert on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("profile error"));
|
||||
renderWithProviders(<Profile />, {
|
||||
route: "/profile/p1",
|
||||
routePath: "/profile/:id",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("profile error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Profile (own view)", () => {
|
||||
it("shows a login prompt when OIDC is disabled", async () => {
|
||||
renderWithProviders(<Profile />, { route: "/profile" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("auth.login")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the edit form for a logged-in user", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
|
||||
renderWithProviders(<Profile />, {
|
||||
route: "/profile",
|
||||
config: makeConfig({
|
||||
oidc_enabled: true,
|
||||
user: { sub: "user-123", name: "Jane" },
|
||||
}),
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Jane Operator")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import type { AppConfig } from "@/types/config";
|
||||
import { apiGet, apiPut } from "@/utils/api";
|
||||
import { qk, invalidate } from "@/utils/queryKeys";
|
||||
import { resolveNodeName, useFormatDateTime } from "@/utils/format";
|
||||
import { hasOperatorOrAdmin } from "@/utils/profileHelpers";
|
||||
import { Loading, ErrorAlert, SuccessAlert } from "@/components/Alerts";
|
||||
import { CallsignBadge, RoleBadge } from "@/components/Badges";
|
||||
import { Breadcrumbs } from "@/components/Breadcrumbs";
|
||||
@@ -32,16 +32,6 @@ interface UserProfileData {
|
||||
nodes?: ProfileNode[] | null;
|
||||
}
|
||||
|
||||
function hasOperatorOrAdmin(
|
||||
roles: string[] | null | undefined,
|
||||
config: AppConfig,
|
||||
): boolean {
|
||||
const roleNames = config.role_names || {};
|
||||
const operatorRole = roleNames.operator || "operator";
|
||||
const adminRole = roleNames.admin || "admin";
|
||||
return !!roles && (roles.includes(operatorRole) || roles.includes(adminRole));
|
||||
}
|
||||
|
||||
function RoleBadges({ roles }: { roles?: string[] | null }) {
|
||||
if (!roles || roles.length === 0) return null;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/charts/Charts", () => ({
|
||||
ActivityChart: () => null,
|
||||
TrendLineChart: () => null,
|
||||
StackedBarChart: () => null,
|
||||
RoutesTrendChart: () => null,
|
||||
RouteDetailStrip: () => null,
|
||||
}));
|
||||
|
||||
import { RoutesPage as Routes } from "@/pages/Routes";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
const ROUTES = {
|
||||
items: [
|
||||
{
|
||||
id: "r1",
|
||||
from_label: "NodeA",
|
||||
to_label: "NodeB",
|
||||
description: "Primary route",
|
||||
visibility: "community",
|
||||
enabled: true,
|
||||
reversible: false,
|
||||
match_width: 60,
|
||||
window_hours: 24,
|
||||
quality_avg: "clear",
|
||||
route_result: { quality: "clear", state: "healthy" },
|
||||
route_nodes: [],
|
||||
route_observers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ROUTE_DETAIL = {
|
||||
id: "r1",
|
||||
from_label: "NodeA",
|
||||
to_label: "NodeB",
|
||||
recent_matches: [],
|
||||
};
|
||||
|
||||
const ROUTE_HISTORY = {
|
||||
buckets: [],
|
||||
};
|
||||
|
||||
function mockRoutesApi() {
|
||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
if (path === "/api/v1/routes") return ROUTES;
|
||||
if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL;
|
||||
if (path.includes("/history")) return ROUTE_HISTORY;
|
||||
throw new Error(`Unexpected: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Routes", () => {
|
||||
it("shows a loading state before data resolves", () => {
|
||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<Routes />);
|
||||
expect(document.querySelector(".loading-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders route cards after data resolves", async () => {
|
||||
mockRoutesApi();
|
||||
renderWithProviders(<Routes />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error on fetch failure", async () => {
|
||||
vi.spyOn(api, "apiGet").mockRejectedValue(new Error("routes error"));
|
||||
renderWithProviders(<Routes />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("routes error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,12 @@ import { Modal } from "@/components/Modal";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SectionGroup } from "@/components/SectionGroup";
|
||||
import { RouteDetailStrip } from "@/components/charts/Charts";
|
||||
import {
|
||||
qualityOf,
|
||||
qualityBadgeClass,
|
||||
qualityLabel,
|
||||
diagnosisText,
|
||||
} from "@/utils/routesHelpers";
|
||||
import {
|
||||
IconClock,
|
||||
IconEdit,
|
||||
@@ -152,38 +158,6 @@ const PATH_MAX = 5;
|
||||
const PATH_HEAD = 2;
|
||||
const PATH_TAIL = 2;
|
||||
|
||||
function qualityOf(route: RouteItem): string {
|
||||
return route.quality_avg || route.route_result?.quality || "unknown";
|
||||
}
|
||||
|
||||
function qualityBadgeClass(quality: string, enabled: boolean): string {
|
||||
if (!enabled) return "badge-neutral";
|
||||
const map: Record<string, string> = {
|
||||
clear: "badge-success",
|
||||
marginal: "badge-warning",
|
||||
failing: "badge-error",
|
||||
no_coverage: "badge-info",
|
||||
unknown: "badge-ghost",
|
||||
};
|
||||
return map[quality] || "badge-ghost";
|
||||
}
|
||||
|
||||
function qualityLabel(
|
||||
quality: string,
|
||||
enabled: boolean,
|
||||
t: TranslateFn,
|
||||
): string {
|
||||
if (!enabled) return t("routes.disabled");
|
||||
const map: Record<string, string> = {
|
||||
clear: t("routes.quality_clear"),
|
||||
marginal: t("routes.quality_marginal"),
|
||||
failing: t("routes.quality_failing"),
|
||||
no_coverage: t("routes.quality_no_coverage"),
|
||||
unknown: t("routes.quality_unknown"),
|
||||
};
|
||||
return map[quality] || quality || t("routes.quality_unknown");
|
||||
}
|
||||
|
||||
function qualityDot(quality: string, enabled: boolean): string {
|
||||
if (!enabled) return "\u25CC";
|
||||
const dots: Record<string, string> = {
|
||||
@@ -196,15 +170,6 @@ function qualityDot(quality: string, enabled: boolean): string {
|
||||
return dots[quality] || "\u25D0";
|
||||
}
|
||||
|
||||
function diagnosisText(route: RouteItem, t: TranslateFn): string {
|
||||
const result = route.route_result;
|
||||
if (!result || !route.enabled) return "";
|
||||
if (result.state === "healthy") return t("routes.diagnosis_healthy");
|
||||
if (result.state === "unhealthy") return t("routes.diagnosis_unhealthy");
|
||||
if (result.state === "no_coverage") return t("routes.diagnosis_no_coverage");
|
||||
return "";
|
||||
}
|
||||
|
||||
function IconRouteFrom(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
import * as api from "@/utils/api";
|
||||
|
||||
export type ApiGetMap = Record<string, unknown>;
|
||||
|
||||
export function mockApiGet(responses: ApiGetMap) {
|
||||
return vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||
const base = path.split("?")[0];
|
||||
if (base in responses) return responses[base];
|
||||
throw new Error(`Unexpected apiGet path: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
export function mockApiGetError(error: Error) {
|
||||
return vi.spyOn(api, "apiGet").mockRejectedValue(error);
|
||||
}
|
||||
|
||||
export function mockApiPost(response: unknown = null) {
|
||||
return vi.spyOn(api, "apiPost").mockResolvedValue(response);
|
||||
}
|
||||
|
||||
export function mockApiPut(response: unknown = null) {
|
||||
return vi.spyOn(api, "apiPut").mockResolvedValue(response);
|
||||
}
|
||||
|
||||
export function mockApiDelete() {
|
||||
return vi.spyOn(api, "apiDelete").mockResolvedValue(undefined);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { render, type RenderOptions } from "@testing-library/react";
|
||||
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
@@ -20,6 +20,7 @@ interface ProviderOptions {
|
||||
config?: AppConfig;
|
||||
client?: QueryClient;
|
||||
route?: string;
|
||||
routePath?: string;
|
||||
renderOptions?: Omit<RenderOptions, "wrapper">;
|
||||
}
|
||||
|
||||
@@ -31,14 +32,22 @@ export function renderWithProviders(
|
||||
config = makeConfig(),
|
||||
client = createTestQueryClient(),
|
||||
route = "/",
|
||||
routePath,
|
||||
renderOptions,
|
||||
} = options;
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const content = routePath ? (
|
||||
<Routes>
|
||||
<Route path={routePath} element={children} />
|
||||
</Routes>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<AppConfigProvider config={config}>
|
||||
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
|
||||
<MemoryRouter initialEntries={[route]}>{content}</MemoryRouter>
|
||||
</AppConfigProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
window.t = (key: string) => key;
|
||||
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getDistanceKm,
|
||||
getNodesWithinRadius,
|
||||
getAnchorPoint,
|
||||
normalizeType,
|
||||
} from "@/utils/mapMath";
|
||||
|
||||
describe("getDistanceKm", () => {
|
||||
it("returns 0 for the same point", () => {
|
||||
expect(getDistanceKm(10, 20, 10, 20)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("calculates distance between two known points", () => {
|
||||
const d = getDistanceKm(51.5074, -0.1278, 48.8566, 2.3522);
|
||||
expect(d).toBeGreaterThan(330);
|
||||
expect(d).toBeLessThan(360);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNodesWithinRadius", () => {
|
||||
const nodes = [
|
||||
{ lat: 0, lon: 0, adv_type: null },
|
||||
{ lat: 0.01, lon: 0.01, adv_type: null },
|
||||
{ lat: 10, lon: 10, adv_type: null },
|
||||
];
|
||||
|
||||
it("filters to only nearby nodes", () => {
|
||||
expect(getNodesWithinRadius(nodes, 0, 0, 100)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns all when the radius is large enough", () => {
|
||||
expect(getNodesWithinRadius(nodes, 0, 0, 2000)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAnchorPoint", () => {
|
||||
it("returns the adopted center when provided", () => {
|
||||
expect(getAnchorPoint([], { lat: 5, lon: 5 })).toEqual({ lat: 5, lon: 5 });
|
||||
});
|
||||
|
||||
it("returns origin for empty nodes with no center", () => {
|
||||
expect(getAnchorPoint([], null)).toEqual({ lat: 0, lon: 0 });
|
||||
});
|
||||
|
||||
it("computes the centroid of multiple nodes", () => {
|
||||
const nodes = [
|
||||
{ lat: 0, lon: 0, adv_type: null },
|
||||
{ lat: 10, lon: 20, adv_type: null },
|
||||
];
|
||||
expect(getAnchorPoint(nodes, null)).toEqual({ lat: 5, lon: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeType", () => {
|
||||
it("lowercases the type string", () => {
|
||||
expect(normalizeType("CHAT")).toBe("chat");
|
||||
});
|
||||
|
||||
it("returns null for null input", () => {
|
||||
expect(normalizeType(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface LatLng {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export interface MapNodeLike {
|
||||
lat: number;
|
||||
lon: number;
|
||||
adv_type?: string | null;
|
||||
}
|
||||
|
||||
export function getDistanceKm(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number,
|
||||
): number {
|
||||
const R = 6371;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
export function getNodesWithinRadius<T extends MapNodeLike>(
|
||||
nodes: T[],
|
||||
anchorLat: number,
|
||||
anchorLon: number,
|
||||
radiusKm: number,
|
||||
): T[] {
|
||||
return nodes.filter(
|
||||
(n) => getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAnchorPoint<T extends MapNodeLike>(
|
||||
nodes: T[],
|
||||
adoptedCenter: LatLng | null,
|
||||
): LatLng {
|
||||
if (adoptedCenter) return adoptedCenter;
|
||||
if (nodes.length === 0) return { lat: 0, lon: 0 };
|
||||
return {
|
||||
lat: nodes.reduce((sum, n) => sum + n.lat, 0) / nodes.length,
|
||||
lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeType(type: string | null): string | null {
|
||||
return type ? type.toLowerCase() : null;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseSenderFromText,
|
||||
collapseNewlines,
|
||||
channelInfo,
|
||||
messageTextWithSender,
|
||||
dedupeBySignature,
|
||||
} from "@/utils/messageHelpers";
|
||||
|
||||
describe("parseSenderFromText", () => {
|
||||
it("extracts the @[sender] pattern", () => {
|
||||
const result = parseSenderFromText("@[Alice]: Hello world");
|
||||
expect(result.sender).toBe("Alice");
|
||||
expect(result.text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("extracts the ack @[sender] pattern", () => {
|
||||
const result = parseSenderFromText("ack @[Bob]: Got it");
|
||||
expect(result.sender).toBe("Bob");
|
||||
expect(result.text).toBe("Got it");
|
||||
});
|
||||
|
||||
it("extracts the plain ack sender pattern", () => {
|
||||
const result = parseSenderFromText("ack Carol: Message");
|
||||
expect(result.sender).toBe("Carol");
|
||||
expect(result.text).toBe("Message");
|
||||
});
|
||||
|
||||
it("returns null sender for non-matching text", () => {
|
||||
const result = parseSenderFromText("Just a message");
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.text).toBe("Just a message");
|
||||
});
|
||||
|
||||
it("returns dash for null input", () => {
|
||||
expect(parseSenderFromText(null).text).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapseNewlines", () => {
|
||||
it("replaces newlines with single spaces", () => {
|
||||
expect(collapseNewlines("line1\nline2")).toBe("line1 line2");
|
||||
});
|
||||
|
||||
it("collapses surrounding whitespace", () => {
|
||||
expect(collapseNewlines("a \n b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("returns null for null input", () => {
|
||||
expect(collapseNewlines(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("channelInfo", () => {
|
||||
const base = {
|
||||
message_type: "channel" as const,
|
||||
text: "hello",
|
||||
received_at: "2024-01-01",
|
||||
};
|
||||
|
||||
it("returns null label for non-channel messages", () => {
|
||||
const result = channelInfo(
|
||||
{ ...base, message_type: "direct" },
|
||||
new Map(),
|
||||
"Fallback",
|
||||
);
|
||||
expect(result.label).toBeNull();
|
||||
expect(result.text).toBe("hello");
|
||||
});
|
||||
|
||||
it("uses the known channel label from the map", () => {
|
||||
const labels = new Map([[17, "Public"]]);
|
||||
const result = channelInfo(
|
||||
{ ...base, text: "[Public] hello", channel_idx: 17 },
|
||||
labels,
|
||||
"Fallback",
|
||||
);
|
||||
expect(result.label).toBe("Public");
|
||||
expect(result.text).toBe("hello");
|
||||
});
|
||||
|
||||
it("falls back to channel_name when no label map match exists", () => {
|
||||
const result = channelInfo(
|
||||
{ ...base, channel_name: "Custom" },
|
||||
new Map(),
|
||||
"Fallback",
|
||||
);
|
||||
expect(result.label).toBe("Custom");
|
||||
});
|
||||
|
||||
it("falls back to Ch <idx> when only channel_idx is available", () => {
|
||||
const result = channelInfo(
|
||||
{ ...base, channel_idx: 5 },
|
||||
new Map(),
|
||||
"Fallback",
|
||||
);
|
||||
expect(result.label).toBe("Ch 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageTextWithSender", () => {
|
||||
const base = {
|
||||
message_type: "channel" as const,
|
||||
text: "hi",
|
||||
received_at: "2024-01-01",
|
||||
};
|
||||
|
||||
it("prefixes with the sender name from msg fields", () => {
|
||||
expect(
|
||||
messageTextWithSender({ ...base, sender_name: "Alice" }, "hi"),
|
||||
).toBe("Alice: hi");
|
||||
});
|
||||
|
||||
it("parses the sender from text when no explicit sender exists", () => {
|
||||
expect(
|
||||
messageTextWithSender({ ...base, text: "@[Bob]: hello" }, "@[Bob]: hello"),
|
||||
).toBe("Bob: hello");
|
||||
});
|
||||
|
||||
it("does not duplicate the sender prefix", () => {
|
||||
expect(
|
||||
messageTextWithSender(
|
||||
{ ...base, text: "Alice: hi", sender_name: "Alice" },
|
||||
"Alice: hi",
|
||||
),
|
||||
).toBe("Alice: hi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedupeBySignature", () => {
|
||||
const base = {
|
||||
message_type: "channel" as const,
|
||||
text: "hello",
|
||||
received_at: "2024-01-01",
|
||||
};
|
||||
|
||||
it("keeps non-channel messages as-is", () => {
|
||||
const items = [{ ...base, message_type: "direct" as const }];
|
||||
expect(dedupeBySignature(items)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("merges channel messages with the same long signature", () => {
|
||||
const items = [
|
||||
{
|
||||
...base,
|
||||
signature: "SIG12345678",
|
||||
observers: [{ public_key: "a" }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
signature: "SIG12345678",
|
||||
observers: [{ public_key: "b" }],
|
||||
},
|
||||
];
|
||||
const result = dedupeBySignature(items);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].observers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps messages with different signatures separate", () => {
|
||||
const items = [
|
||||
{ ...base, signature: "SIGAAAAAA" },
|
||||
{ ...base, signature: "SIGBBBBBB" },
|
||||
];
|
||||
expect(dedupeBySignature(items)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not dedupe channel messages with short signatures", () => {
|
||||
const items = [{ ...base, signature: "short" }];
|
||||
expect(dedupeBySignature(items)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { resolveChannelLabel } from "@/context/AppConfigContext";
|
||||
|
||||
export interface ObserverInfo {
|
||||
public_key?: string | null;
|
||||
node_id?: string | null;
|
||||
observed_at?: string | null;
|
||||
snr?: number | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
message_type: string;
|
||||
text: string;
|
||||
channel_idx?: number | null;
|
||||
channel_name?: string | null;
|
||||
signature?: string | null;
|
||||
pubkey_prefix?: string | null;
|
||||
sender_name?: string | null;
|
||||
sender_tag_name?: string | null;
|
||||
observed_by?: string | null;
|
||||
observer_name?: string | null;
|
||||
observer_tag_name?: string | null;
|
||||
received_at: string;
|
||||
packet_hash?: string | null;
|
||||
spam_score?: number | null;
|
||||
observers?: ObserverInfo[];
|
||||
}
|
||||
|
||||
export function parseSenderFromText(text: string | null): {
|
||||
sender: string | null;
|
||||
text: string;
|
||||
} {
|
||||
if (!text || typeof text !== "string") {
|
||||
return { sender: null, text: text || "-" };
|
||||
}
|
||||
const patterns = [
|
||||
/^\s*ack\s+@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
|
||||
/^\s*@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
|
||||
/^\s*ack\s+([^:|\n]{1,80})\s*:\s*([\s\S]+)$/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (!match) continue;
|
||||
const sender = (match[1] || "").trim();
|
||||
const remaining = (match[2] || "").trim();
|
||||
if (!sender) continue;
|
||||
return { sender, text: remaining || text };
|
||||
}
|
||||
return { sender: null, text };
|
||||
}
|
||||
|
||||
export function collapseNewlines(text: string | null): string | null {
|
||||
if (!text || typeof text !== "string") return text;
|
||||
return text.replace(/\s*\n\s*/g, " ");
|
||||
}
|
||||
|
||||
export function channelInfo(
|
||||
msg: Message,
|
||||
channelLabels: Map<number, string>,
|
||||
fallbackLabel: string,
|
||||
): { label: string | null; text: string } {
|
||||
if (msg.message_type !== "channel") {
|
||||
return { label: null, text: msg.text || "-" };
|
||||
}
|
||||
const rawText = msg.text || "";
|
||||
const match = rawText.match(/^\[([^\]]+)\]\s+([\s\S]*)$/);
|
||||
if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
|
||||
const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
|
||||
if (knownLabel) {
|
||||
return {
|
||||
label: knownLabel,
|
||||
text: match ? match[2] || "-" : rawText || "-",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (msg.channel_name) {
|
||||
return { label: msg.channel_name, text: msg.text || "-" };
|
||||
}
|
||||
if (match) {
|
||||
return { label: match[1], text: match[2] || "-" };
|
||||
}
|
||||
if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
|
||||
const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
|
||||
return { label: knownLabel || `Ch ${msg.channel_idx}`, text: rawText || "-" };
|
||||
}
|
||||
return { label: fallbackLabel, text: rawText || "-" };
|
||||
}
|
||||
|
||||
export function messageTextWithSender(msg: Message, text: string): string {
|
||||
const parsed = parseSenderFromText(text || "-");
|
||||
const explicitSender =
|
||||
msg.sender_tag_name ||
|
||||
msg.sender_name ||
|
||||
(msg.pubkey_prefix || "").slice(0, 12) ||
|
||||
null;
|
||||
const sender = explicitSender || parsed.sender;
|
||||
const body = collapseNewlines((parsed.text || text || "-").trim()) || "-";
|
||||
if (!sender) return body;
|
||||
if (body.toLowerCase().startsWith(`${sender.toLowerCase()}:`)) return body;
|
||||
return `${sender}: ${body}`;
|
||||
}
|
||||
|
||||
export function dedupeBySignature<T extends Message>(items: T[]): T[] {
|
||||
const deduped: T[] = [];
|
||||
const bySignature = new Map<string, T>();
|
||||
|
||||
for (const msg of items) {
|
||||
const signature =
|
||||
typeof msg.signature === "string"
|
||||
? msg.signature.trim().toUpperCase()
|
||||
: "";
|
||||
const canDedupe = msg.message_type === "channel" && signature.length >= 8;
|
||||
if (!canDedupe) {
|
||||
deduped.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = bySignature.get(signature);
|
||||
if (!existing) {
|
||||
const clone: T = {
|
||||
...msg,
|
||||
observers: [...(msg.observers ?? [])],
|
||||
} as T;
|
||||
bySignature.set(signature, clone);
|
||||
deduped.push(clone);
|
||||
continue;
|
||||
}
|
||||
|
||||
const combined = [...(existing.observers ?? []), ...(msg.observers ?? [])];
|
||||
const seenReceivers = new Set<string>();
|
||||
existing.observers = combined.filter((recv) => {
|
||||
const key =
|
||||
recv?.public_key ||
|
||||
recv?.node_id ||
|
||||
`${recv?.observed_at ?? ""}:${recv?.snr ?? ""}`;
|
||||
if (seenReceivers.has(key)) return false;
|
||||
seenReceivers.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!existing.observed_by && msg.observed_by)
|
||||
existing.observed_by = msg.observed_by;
|
||||
if (!existing.observer_name && msg.observer_name)
|
||||
existing.observer_name = msg.observer_name;
|
||||
if (!existing.observer_tag_name && msg.observer_tag_name)
|
||||
existing.observer_tag_name = msg.observer_tag_name;
|
||||
if (!existing.pubkey_prefix && msg.pubkey_prefix)
|
||||
existing.pubkey_prefix = msg.pubkey_prefix;
|
||||
if (!existing.sender_name && msg.sender_name)
|
||||
existing.sender_name = msg.sender_name;
|
||||
if (!existing.sender_tag_name && msg.sender_tag_name)
|
||||
existing.sender_tag_name = msg.sender_tag_name;
|
||||
if (!existing.channel_name && msg.channel_name)
|
||||
existing.channel_name = msg.channel_name;
|
||||
if (
|
||||
existing.channel_name === "Public" &&
|
||||
msg.channel_name &&
|
||||
msg.channel_name !== "Public"
|
||||
) {
|
||||
existing.channel_name = msg.channel_name;
|
||||
}
|
||||
if (existing.channel_idx === null || existing.channel_idx === undefined) {
|
||||
if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
|
||||
existing.channel_idx = msg.channel_idx;
|
||||
}
|
||||
} else if (
|
||||
existing.channel_idx === 17 &&
|
||||
msg.channel_idx !== null &&
|
||||
msg.channel_idx !== undefined &&
|
||||
msg.channel_idx !== 17
|
||||
) {
|
||||
existing.channel_idx = msg.channel_idx;
|
||||
}
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { groupByObserver } from "@/utils/packetGroupHelpers";
|
||||
|
||||
describe("groupByObserver", () => {
|
||||
it("groups receptions by observed_by", () => {
|
||||
const receptions = [
|
||||
{ observed_by: "a", snr: 1 },
|
||||
{ observed_by: "b", snr: 2 },
|
||||
{ observed_by: "a", snr: 3 },
|
||||
];
|
||||
const groups = groupByObserver(receptions);
|
||||
expect(groups.size).toBe(2);
|
||||
expect(groups.get("a")).toHaveLength(2);
|
||||
expect(groups.get("b")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses __unknown__ key for null observed_by", () => {
|
||||
const groups = groupByObserver([{ observed_by: null }]);
|
||||
expect(groups.has("__unknown__")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an empty map for empty input", () => {
|
||||
expect(groupByObserver([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface ReceptionLike {
|
||||
observed_by?: string | null;
|
||||
}
|
||||
|
||||
export function groupByObserver<T extends ReceptionLike>(
|
||||
receptions: T[],
|
||||
): Map<string, T[]> {
|
||||
const groups = new Map<string, T[]>();
|
||||
for (const r of receptions) {
|
||||
const key = r.observed_by || "__unknown__";
|
||||
const list = groups.get(key);
|
||||
if (list) {
|
||||
list.push(r);
|
||||
} else {
|
||||
groups.set(key, [r]);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildChannelList, packetUrl } from "@/utils/packetHelpers";
|
||||
|
||||
describe("buildChannelList", () => {
|
||||
it("parses channel_hash hex into a numeric idx", () => {
|
||||
const result = buildChannelList([
|
||||
{ name: "Public", channel_hash: "11" },
|
||||
{ name: "Custom", channel_hash: "ff" },
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ name: "Public", idx: 17 },
|
||||
{ name: "Custom", idx: 255 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out entries with non-hex hashes", () => {
|
||||
expect(buildChannelList([{ name: "Bad", channel_hash: "xyz" }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("packetUrl", () => {
|
||||
it("uses the hash route when packet_hash exists", () => {
|
||||
expect(packetUrl({ packet_hash: "abc123" })).toBe("/packets/hash/abc123");
|
||||
});
|
||||
|
||||
it("falls back to the first reception packet_id", () => {
|
||||
expect(
|
||||
packetUrl({ receptions: [{ packet_id: "p1" }, { packet_id: "p2" }] }),
|
||||
).toBe("/packets/p1");
|
||||
});
|
||||
|
||||
it("falls back to /packets when nothing is available", () => {
|
||||
expect(packetUrl({})).toBe("/packets");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ChannelItem } from "@/utils/packets";
|
||||
|
||||
export interface ChannelEntry {
|
||||
name: string;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
export interface PacketGroupItemLike {
|
||||
packet_hash?: string | null;
|
||||
receptions?: { packet_id: string }[];
|
||||
}
|
||||
|
||||
export function buildChannelList(items: ChannelItem[]): ChannelEntry[] {
|
||||
return items
|
||||
.map((c) => ({ name: c.name, idx: parseInt(c.channel_hash, 16) }))
|
||||
.filter((c) => !Number.isNaN(c.idx));
|
||||
}
|
||||
|
||||
export function packetUrl(p: PacketGroupItemLike): string {
|
||||
if (p.packet_hash) return `/packets/hash/${p.packet_hash}`;
|
||||
if (p.receptions && p.receptions.length > 0)
|
||||
return `/packets/${p.receptions[0].packet_id}`;
|
||||
return "/packets";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasOperatorOrAdmin } from "@/utils/profileHelpers";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
|
||||
describe("hasOperatorOrAdmin", () => {
|
||||
it("returns true when roles include operator", () => {
|
||||
expect(hasOperatorOrAdmin(["operator"], makeConfig())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when roles include admin", () => {
|
||||
expect(hasOperatorOrAdmin(["admin"], makeConfig())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for member-only roles", () => {
|
||||
expect(hasOperatorOrAdmin(["member"], makeConfig())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null roles", () => {
|
||||
expect(hasOperatorOrAdmin(null, makeConfig())).toBe(false);
|
||||
});
|
||||
|
||||
it("respects custom role names from config", () => {
|
||||
const config = makeConfig({ role_names: { operator: "netcop" } });
|
||||
expect(hasOperatorOrAdmin(["netcop"], config)).toBe(true);
|
||||
expect(hasOperatorOrAdmin(["operator"], config)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AppConfig } from "@/types/config";
|
||||
|
||||
export function hasOperatorOrAdmin(
|
||||
roles: string[] | null | undefined,
|
||||
config: AppConfig,
|
||||
): boolean {
|
||||
const roleNames = config.role_names || {};
|
||||
const operatorRole = roleNames.operator || "operator";
|
||||
const adminRole = roleNames.admin || "admin";
|
||||
return !!roles && (roles.includes(operatorRole) || roles.includes(adminRole));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
qualityOf,
|
||||
qualityBadgeClass,
|
||||
qualityLabel,
|
||||
diagnosisText,
|
||||
} from "@/utils/routesHelpers";
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("qualityOf", () => {
|
||||
it("prefers quality_avg over route_result", () => {
|
||||
expect(
|
||||
qualityOf({ quality_avg: "clear", route_result: { quality: "failing" } }),
|
||||
).toBe("clear");
|
||||
});
|
||||
|
||||
it("falls back to route_result.quality when quality_avg is empty", () => {
|
||||
expect(
|
||||
qualityOf({ quality_avg: null, route_result: { quality: "marginal" } }),
|
||||
).toBe("marginal");
|
||||
});
|
||||
|
||||
it("returns unknown when neither is available", () => {
|
||||
expect(qualityOf({})).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualityBadgeClass", () => {
|
||||
it("returns neutral when disabled", () => {
|
||||
expect(qualityBadgeClass("clear", false)).toBe("badge-neutral");
|
||||
});
|
||||
|
||||
it("returns the correct class for each known quality", () => {
|
||||
expect(qualityBadgeClass("clear", true)).toBe("badge-success");
|
||||
expect(qualityBadgeClass("marginal", true)).toBe("badge-warning");
|
||||
expect(qualityBadgeClass("failing", true)).toBe("badge-error");
|
||||
expect(qualityBadgeClass("no_coverage", true)).toBe("badge-info");
|
||||
expect(qualityBadgeClass("unknown", true)).toBe("badge-ghost");
|
||||
});
|
||||
|
||||
it("returns ghost for unmapped qualities", () => {
|
||||
expect(qualityBadgeClass("bizarre", true)).toBe("badge-ghost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualityLabel", () => {
|
||||
it("returns the disabled label when not enabled", () => {
|
||||
expect(qualityLabel("clear", false, t)).toBe("routes.disabled");
|
||||
});
|
||||
|
||||
it("returns the translated label for a known quality", () => {
|
||||
expect(qualityLabel("clear", true, t)).toBe("routes.quality_clear");
|
||||
expect(qualityLabel("failing", true, t)).toBe("routes.quality_failing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnosisText", () => {
|
||||
it("returns empty string when there is no route result", () => {
|
||||
expect(diagnosisText({ enabled: true }, t)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when the route is disabled", () => {
|
||||
expect(
|
||||
diagnosisText(
|
||||
{ enabled: false, route_result: { state: "healthy" } },
|
||||
t,
|
||||
),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("returns the healthy diagnosis", () => {
|
||||
expect(
|
||||
diagnosisText({ enabled: true, route_result: { state: "healthy" } }, t),
|
||||
).toBe("routes.diagnosis_healthy");
|
||||
});
|
||||
|
||||
it("returns the unhealthy diagnosis", () => {
|
||||
expect(
|
||||
diagnosisText({ enabled: true, route_result: { state: "unhealthy" } }, t),
|
||||
).toBe("routes.diagnosis_unhealthy");
|
||||
});
|
||||
|
||||
it("returns the no_coverage diagnosis", () => {
|
||||
expect(
|
||||
diagnosisText(
|
||||
{ enabled: true, route_result: { state: "no_coverage" } },
|
||||
t,
|
||||
),
|
||||
).toBe("routes.diagnosis_no_coverage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export type TranslateFn = (
|
||||
key: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => string;
|
||||
|
||||
export interface RouteResultLike {
|
||||
quality?: string | null;
|
||||
state?: string | null;
|
||||
}
|
||||
|
||||
export interface RouteItemLike {
|
||||
quality_avg?: string | null;
|
||||
enabled?: boolean;
|
||||
route_result?: RouteResultLike | null;
|
||||
}
|
||||
|
||||
export function qualityOf(route: RouteItemLike): string {
|
||||
return route.quality_avg || route.route_result?.quality || "unknown";
|
||||
}
|
||||
|
||||
export function qualityBadgeClass(quality: string, enabled: boolean): string {
|
||||
if (!enabled) return "badge-neutral";
|
||||
const map: Record<string, string> = {
|
||||
clear: "badge-success",
|
||||
marginal: "badge-warning",
|
||||
failing: "badge-error",
|
||||
no_coverage: "badge-info",
|
||||
unknown: "badge-ghost",
|
||||
};
|
||||
return map[quality] || "badge-ghost";
|
||||
}
|
||||
|
||||
export function qualityLabel(
|
||||
quality: string,
|
||||
enabled: boolean,
|
||||
t: TranslateFn,
|
||||
): string {
|
||||
if (!enabled) return t("routes.disabled");
|
||||
const map: Record<string, string> = {
|
||||
clear: t("routes.quality_clear"),
|
||||
marginal: t("routes.quality_marginal"),
|
||||
failing: t("routes.quality_failing"),
|
||||
no_coverage: t("routes.quality_no_coverage"),
|
||||
unknown: t("routes.quality_unknown"),
|
||||
};
|
||||
return map[quality] || quality || t("routes.quality_unknown");
|
||||
}
|
||||
|
||||
export function diagnosisText(route: RouteItemLike, t: TranslateFn): string {
|
||||
const result = route.route_result;
|
||||
if (!result || !route.enabled) return "";
|
||||
if (result.state === "healthy") return t("routes.diagnosis_healthy");
|
||||
if (result.state === "unhealthy") return t("routes.diagnosis_unhealthy");
|
||||
if (result.state === "no_coverage") return t("routes.diagnosis_no_coverage");
|
||||
return "";
|
||||
}
|
||||
@@ -414,7 +414,8 @@
|
||||
"empty_description": "Members will appear here once users log in and adopt nodes."
|
||||
},
|
||||
"footer": {
|
||||
"powered_by": "Powered by"
|
||||
"powered_by": "Powered by",
|
||||
"tagline": "Off-Grid, Open-Source Encrypted Messaging"
|
||||
},
|
||||
"errors": {
|
||||
"go_home": "Go Home",
|
||||
|
||||
@@ -327,6 +327,7 @@
|
||||
"copied_entities": "{{copied}} label(s) gekopieerd, {{skipped}} overgeslagen"
|
||||
},
|
||||
"footer": {
|
||||
"powered_by": "Mogelijk gemaakt door"
|
||||
"powered_by": "Mogelijk gemaakt door",
|
||||
"tagline": "Off-Grid, Open-Source Versleutelde Berichten"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,53 +53,9 @@
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ version }}">
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 flex flex-col">
|
||||
<!-- React shell: navbar, announcements, and routed page content render here -->
|
||||
<!-- React shell: navbar, announcements, routed page content, and footer render here -->
|
||||
<div id="app" class="flex-1 flex flex-col"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer p-4 bg-base-100 text-base-content mt-auto">
|
||||
<!-- Left: MeshCore branding (stacks on top on mobile) -->
|
||||
<div class="flex flex-col items-center gap-1 order-2 lg:order-1">
|
||||
<a href="https://meshcore.io/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity flex mb-1">
|
||||
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo theme-logo--invert-light h-4" />
|
||||
</a>
|
||||
<span class="text-xs opacity-50">Off-Grid, Open-Source Encrypted Messaging</span>
|
||||
<p class="text-sm opacity-70">
|
||||
<a href="https://meshcore.io/" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.website') }}</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.github') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right: Network info + Hub attribution -->
|
||||
<div class="flex flex-col items-center gap-1 order-1 lg:order-2">
|
||||
<p>
|
||||
{{ network_name }}
|
||||
{% if network_city and network_country %}
|
||||
| {{ network_city }}, {{ network_country }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50">{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
<p class="text-sm opacity-70">
|
||||
{% if network_contact_email %}
|
||||
<a href="mailto:{{ network_contact_email }}" class="link link-hover">{{ network_contact_email }}</a>
|
||||
{% endif %}
|
||||
{% if network_contact_email and network_contact_discord %} | {% endif %}
|
||||
{% if network_contact_discord %}
|
||||
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.discord') }}</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord) and network_contact_github %} | {% endif %}
|
||||
{% if network_contact_github %}
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.github') }}</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord or network_contact_github) and network_contact_youtube %} | {% endif %}
|
||||
{% if network_contact_youtube %}
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Embedded app configuration -->
|
||||
<script>
|
||||
window.__APP_CONFIG__ = {{ config_json|safe }};
|
||||
|
||||
+58
-17
@@ -383,10 +383,15 @@ class TestFlashBannerVisibility:
|
||||
|
||||
|
||||
class TestFlashBannerMarkdown:
|
||||
"""Tests for Markdown rendering in the flash banner."""
|
||||
"""Tests that raw markdown is shipped in the SPA config for client-side rendering.
|
||||
|
||||
def test_bold_rendered(self, mock_http_client: MockHttpClient) -> None:
|
||||
"""Markdown bold is rendered to <strong> in the config content."""
|
||||
The backend no longer converts markdown to HTML; the React ``<Markdown>``
|
||||
component (react-markdown + remark-gfm) renders it. Raw HTML in the source
|
||||
is preserved here but escaped by the client renderer (no rehype-raw).
|
||||
"""
|
||||
|
||||
def test_bold_source_preserved(self, mock_http_client: MockHttpClient) -> None:
|
||||
"""Raw markdown bold syntax is shipped verbatim in the config."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -397,10 +402,10 @@ class TestFlashBannerMarkdown:
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert "<strong>important</strong>" in config["network_announcement"]
|
||||
assert "**important**" in config["network_announcement"]
|
||||
|
||||
def test_link_rendered(self, mock_http_client: MockHttpClient) -> None:
|
||||
"""Markdown link is rendered to <a> tag in the config content."""
|
||||
def test_link_source_preserved(self, mock_http_client: MockHttpClient) -> None:
|
||||
"""Raw markdown link syntax is shipped verbatim in the config."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -411,17 +416,16 @@ class TestFlashBannerMarkdown:
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert (
|
||||
'<a href="https://example.com">click here</a>'
|
||||
in config["network_announcement"]
|
||||
)
|
||||
assert "[click here](https://example.com)" 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.
|
||||
def test_raw_html_preserved_but_escaped_client_side(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Raw HTML in announcement is shipped as-is; the client escapes it.
|
||||
|
||||
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. The React banner renders it via dangerouslySetInnerHTML.
|
||||
The backend trusts the operator-controlled source (same trust model as
|
||||
custom pages). Client-side rendering via react-markdown escapes raw HTML
|
||||
by default (no rehype-raw), so it is displayed as text, not rendered.
|
||||
"""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
@@ -442,7 +446,7 @@ class TestSystemAnnouncementBanner:
|
||||
def test_system_banner_present_when_set(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""System banner content is exposed and Markdown-rendered in the config."""
|
||||
"""System banner raw markdown is exposed in the config for client rendering."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -454,7 +458,7 @@ class TestSystemAnnouncementBanner:
|
||||
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["system_announcement"]
|
||||
assert "<strong>Outage</strong> at 22:00" in 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 content is absent from the config when not set."""
|
||||
@@ -691,3 +695,40 @@ class TestBootstrapHeaderSanitization:
|
||||
forwarded = mock_http_client.last_get_headers
|
||||
assert forwarded is not None
|
||||
assert forwarded["X-User-Name"] == "Matt"
|
||||
|
||||
|
||||
class TestSpaShellAndErrorFallback:
|
||||
"""Lock the shell-vs-error split.
|
||||
|
||||
The SPA shell (``spa.html``) is pure bootstrap served for every GET route;
|
||||
React renders content (including 404s for unknown routes) client-side.
|
||||
``error.html`` is the minimal non-React fallback served only when the server
|
||||
itself errors before React can boot.
|
||||
"""
|
||||
|
||||
def test_unknown_get_route_serves_spa_shell(self, client: TestClient) -> None:
|
||||
"""Unknown GET routes hit the catch-all → SPA shell (React renders 404)."""
|
||||
response = client.get("/this-route-does-not-exist-anywhere")
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
assert 'id="app"' in response.text
|
||||
|
||||
def test_500_serves_error_html_fallback(
|
||||
self, web_app: Any, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""A server error serves the minimal error.html fallback, not the SPA.
|
||||
|
||||
Triggered by forcing the catch-all's config builder to raise; the generic
|
||||
exception handler then renders ``error.html`` (route-order-independent —
|
||||
a route added after the catch-all would be shadowed by ``/{path:path}``).
|
||||
"""
|
||||
web_app.state.http_client = mock_http_client
|
||||
client = TestClient(web_app, raise_server_exceptions=False)
|
||||
with patch(
|
||||
"meshcore_hub.web.app._build_config_json",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 500
|
||||
assert "Internal server error" in response.text
|
||||
assert "Go Home" in response.text
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Tests for the home page route (SPA)."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.test_web.conftest import get_app_config
|
||||
|
||||
|
||||
class TestHomePage:
|
||||
"""Tests for the home page."""
|
||||
@@ -40,42 +40,17 @@ class TestHomePage:
|
||||
|
||||
def test_home_config_contains_network_info(self, client: TestClient) -> None:
|
||||
"""Test that SPA config contains network information."""
|
||||
response = client.get("/")
|
||||
# Extract the config JSON from the HTML
|
||||
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.get("/").text)
|
||||
assert config["network_name"] == "Test Network"
|
||||
assert config["network_city"] == "Test City"
|
||||
assert config["network_country"] == "Test Country"
|
||||
|
||||
def test_home_config_contains_contact_info(self, client: TestClient) -> None:
|
||||
"""Test that SPA config contains contact information."""
|
||||
response = client.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])
|
||||
|
||||
"""Test that SPA config contains contact information for the React footer."""
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["network_contact_email"] == "test@example.com"
|
||||
assert config["network_contact_discord"] == "https://discord.gg/test"
|
||||
|
||||
def test_home_contains_contact_email(self, client: TestClient) -> None:
|
||||
"""Test that home page contains the contact email in footer."""
|
||||
response = client.get("/")
|
||||
assert "test@example.com" in response.text
|
||||
|
||||
def test_home_contains_discord_link(self, client: TestClient) -> None:
|
||||
"""Test that home page contains the Discord link in footer."""
|
||||
response = client.get("/")
|
||||
assert "discord.gg/test" 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("/")
|
||||
|
||||
@@ -21,7 +21,7 @@ class TestCustomPage:
|
||||
slug="about",
|
||||
title="About Us",
|
||||
menu_order=10,
|
||||
content_html="<p>Content</p>",
|
||||
content_markdown="# Content",
|
||||
file_path="/pages/about.md",
|
||||
)
|
||||
assert page.url == "/pages/about"
|
||||
@@ -32,7 +32,7 @@ class TestCustomPage:
|
||||
slug="terms-of-service",
|
||||
title="Terms of Service",
|
||||
menu_order=50,
|
||||
content_html="<p>Terms</p>",
|
||||
content_markdown="# Terms",
|
||||
file_path="/pages/terms-of-service.md",
|
||||
)
|
||||
assert page.url == "/pages/terms-of-service"
|
||||
@@ -79,8 +79,9 @@ This is the about page.
|
||||
assert pages[0].slug == "about"
|
||||
assert pages[0].title == "About Us"
|
||||
assert pages[0].menu_order == 10
|
||||
assert "About</h1>" in pages[0].content_html
|
||||
assert "<p>This is the about page.</p>" in pages[0].content_html
|
||||
# Raw markdown body is preserved verbatim (rendered client-side)
|
||||
assert "# About" in pages[0].content_markdown
|
||||
assert "This is the about page." in pages[0].content_markdown
|
||||
|
||||
def test_load_pages_default_slug_from_filename(self) -> None:
|
||||
"""Test that slug defaults to filename when not specified."""
|
||||
@@ -272,8 +273,8 @@ New content.
|
||||
assert len(pages) == 1
|
||||
assert pages[0].slug == "page"
|
||||
|
||||
def test_markdown_tables_rendered(self) -> None:
|
||||
"""Test that markdown tables are rendered to HTML."""
|
||||
def test_markdown_tables_preserved(self) -> None:
|
||||
"""Test that GFM table markdown is preserved verbatim for client rendering."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "tables.md").write_text("""---
|
||||
title: Tables
|
||||
@@ -289,11 +290,12 @@ title: Tables
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert "<table>" in pages[0].content_html
|
||||
assert "<th>" in pages[0].content_html
|
||||
md = pages[0].content_markdown
|
||||
assert "| Header 1 | Header 2 |" in md
|
||||
assert "| Cell 1" in md
|
||||
|
||||
def test_markdown_fenced_code_rendered(self) -> None:
|
||||
"""Test that fenced code blocks are rendered."""
|
||||
def test_markdown_fenced_code_preserved(self) -> None:
|
||||
"""Test that fenced code blocks are preserved verbatim for client rendering."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "code.md").write_text("""---
|
||||
title: Code
|
||||
@@ -310,11 +312,12 @@ def hello():
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
assert "<pre>" in pages[0].content_html
|
||||
assert "def hello():" in pages[0].content_html
|
||||
md = pages[0].content_markdown
|
||||
assert "```python" in md
|
||||
assert "def hello():" in md
|
||||
|
||||
def test_markdown_nested_unordered_list(self) -> None:
|
||||
"""Test that nested unordered lists produce nested <ul> elements."""
|
||||
def test_markdown_nested_unordered_list_preserved(self) -> None:
|
||||
"""Test that nested unordered list markdown is preserved verbatim."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "nested.md").write_text("""---
|
||||
title: Nested
|
||||
@@ -332,17 +335,13 @@ title: Nested
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
html = pages[0].content_html
|
||||
assert "<ul>" in html
|
||||
assert "<li>Item 1" in html
|
||||
assert "<li>Sub item A" in html
|
||||
assert "<li>Deep item" in html
|
||||
outer_ul = html.index("<ul>")
|
||||
inner_ul = html.index("<ul>", outer_ul + 1)
|
||||
assert inner_ul > outer_ul
|
||||
md = pages[0].content_markdown
|
||||
assert "- Item 1" in md
|
||||
assert "- Sub item A" in md
|
||||
assert "- Deep item" in md
|
||||
|
||||
def test_markdown_nested_ordered_list(self) -> None:
|
||||
"""Test that nested ordered lists produce nested <ol> elements."""
|
||||
def test_markdown_nested_ordered_list_preserved(self) -> None:
|
||||
"""Test that nested ordered list markdown is preserved verbatim."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "nested-ol.md").write_text("""---
|
||||
title: Nested OL
|
||||
@@ -359,13 +358,9 @@ title: Nested OL
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
html = pages[0].content_html
|
||||
assert "<ol>" in html
|
||||
assert "<li>First" in html
|
||||
assert "<li>Sub first" in html
|
||||
outer_ol = html.index("<ol>")
|
||||
inner_ol = html.index("<ol>", outer_ol + 1)
|
||||
assert inner_ol > outer_ol
|
||||
md = pages[0].content_markdown
|
||||
assert "1. First" in md
|
||||
assert "1. Sub first" in md
|
||||
|
||||
|
||||
class TestPagesRoute:
|
||||
@@ -464,8 +459,8 @@ Here are some answers.
|
||||
data = response.json()
|
||||
assert data["slug"] == "about"
|
||||
assert data["title"] == "About Us"
|
||||
assert "About Our Network" in data["content_html"]
|
||||
assert "Welcome to the network" in data["content_html"]
|
||||
assert "About Our Network" in data["content_markdown"]
|
||||
assert "Welcome to the network" in data["content_markdown"]
|
||||
|
||||
def test_spa_page_api_not_found(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that /spa/pages/{slug} returns 404 for unknown page."""
|
||||
@@ -481,7 +476,7 @@ Here are some answers.
|
||||
data = response.json()
|
||||
assert data["slug"] == "faq"
|
||||
assert data["title"] == "FAQ"
|
||||
assert "Frequently Asked Questions" in data["content_html"]
|
||||
assert "Frequently Asked Questions" in data["content_markdown"]
|
||||
|
||||
def test_pages_in_navigation(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that custom pages are exposed for the React navigation."""
|
||||
|
||||
Reference in New Issue
Block a user