mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 01:13:11 +02:00
feat(web): frontend CI, vitest suite, navbar→React SPA shell — Phase 5
Frontend CI (closes the no-coverage gap for the TSX): - ci.yml: new 'frontend' job — npm ci, tsc --noEmit, test:frontend, build. - package.json: engines.node>=20, test:frontend/typecheck scripts. vitest unit + component tests (jsdom, @testing-library/react): - utils/charts.test.ts — tier math + every chart builder. - utils/format.test.ts — parseAppDate, formatNumber, truncateKey, emoji helpers, formatRelativeTime. - components/Navbar.test.tsx — feature-gated nav, custom pages, OIDC/maintenance auth gating. - components/Announcements.test.tsx — banner rendering, ordering, dismiss + sessionStorage (covers behaviour moved out of the Python suite). Navbar → React (full SPA shell): - New Navbar/ThemeToggle/Announcements components + useNavItems hook (shared feature-gated nav for desktop + mobile); nav uses react-router NavLink (client-side nav + auto active) — drops the imperative data-nav-link bridge. - main.tsx single root; App.tsx renders Navbar + Announcements above routed <main>. - spa.html slimmed to SEO/config/footer shell (Jinja2 navbar/banners/theme script removed; #app is a plain div React fills). - Backend: _build_config_json exposes system_announcement/network_announcement. Tests (server-rendered nav/banner assertions → config): - conftest.py: get_app_config() helper (robust __APP_CONFIG__ extraction). - test_features/app/home/pages/dashboard rewritten to assert __APP_CONFIG__; dashboard client-rendered-stats tests replaced with a shell assertion. Docs: REACT_MIGRATION.md Phase 5 (incl. deliberately-skipped react-query/ Storybook/Playwright), AGENTS.md Frontend section. Verified: tsc clean, npm run build, vitest (49 passed), pytest tests/test_web (251 passed), full suite (1459 passed), pre-commit (passed).
This commit is contained in:
@@ -36,6 +36,31 @@ jobs:
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:frontend
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -44,16 +44,18 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core ex
|
||||
|
||||
The web UI is a **React 19 + TypeScript + Vite** SPA in
|
||||
`src/meshcore_hub/web/static/js/spa-react/` (alias `@/` → that dir). The Jinja2 shell
|
||||
(`web/templates/spa.html`) renders the navbar/SEO/`window.__APP_CONFIG__`; React mounts into
|
||||
`<main id="app">`. **Frontend tooling runs on the host** (not in Docker): `npm install`,
|
||||
`npm run build` (Tailwind → vendor fonts → `vite build` → `static/dist/` + `assets.json`), and
|
||||
`npx tsc --noEmit` (the TS gate — there is no JS linter in pre-commit). The Vite build is
|
||||
required to serve the UI; there is no fallback bundle.
|
||||
(`web/templates/spa.html`) renders only SEO/`window.__APP_CONFIG__`/footer; React renders the
|
||||
navbar, banners, and routed pages into `<div id="app">`. **Frontend tooling runs on the host**
|
||||
(not in Docker): `npm install`, `npm run build` (Tailwind → vendor fonts → `vite build` →
|
||||
`static/dist/` + `assets.json`), `npx tsc --noEmit` (the TS gate — there is no JS linter in
|
||||
pre-commit), and `npm run test:frontend` (vitest). The Vite build is required to serve the UI;
|
||||
there is no fallback bundle.
|
||||
|
||||
```bash
|
||||
npm install # host: install frontend deps
|
||||
npm run build # host: produce static/dist/ + assets.json
|
||||
npx tsc --noEmit # host: typecheck (must be clean)
|
||||
npm run test:frontend # host: vitest unit + component tests
|
||||
```
|
||||
|
||||
- Charts: **react-chartjs-2** — typed config builders in `utils/charts.ts`, wrappers in
|
||||
@@ -62,9 +64,15 @@ npx tsc --noEmit # host: typecheck (must be clean)
|
||||
That CSS ships in the Vite bundle, which `spa.html` loads in `<head>` **before** `app.css` so
|
||||
the dark-mode map overrides win — don't reorder those `<link>`s.
|
||||
- QR codes: **react-qr-code**.
|
||||
- Navbar/shell: React (`components/Navbar.tsx`, `ThemeToggle.tsx`, `Announcements.tsx`,
|
||||
`hooks/useNavItems.tsx`); nav uses react-router `NavLink` (client-side nav). Feature flags,
|
||||
custom pages, and announcements all come from `window.__APP_CONFIG__`.
|
||||
- Page conventions: `useSearchParams()` for filters/pagination/sort, typed `apiGet<T>()` with an
|
||||
`AbortController` in `useEffect`, `usePageTitle('entities.x')`, shared components
|
||||
(`Pagination`, `FilterForm`, `StatCard`, `NodeDisplay`, etc.).
|
||||
- Tests: **vitest** + `@testing-library/react` (`*.test.ts(x)` next to code; setup in
|
||||
`spa-react/test/`). Python web tests assert the embedded `__APP_CONFIG__`
|
||||
(`tests/test_web/conftest.py::get_app_config`), not server-rendered nav HTML.
|
||||
- Only **fonts** are vendored (`build.js` copies them); chart/map/QR libs are bundled by Vite.
|
||||
|
||||
## Tests & Quality
|
||||
|
||||
+33
-7
@@ -10,7 +10,7 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite.
|
||||
| 2 | Convert pages one-by-one from LitBridge to native React | **Complete** |
|
||||
| 3 | Chart & map components (react-chartjs-2, react-leaflet) | **Complete** |
|
||||
| 4 | Cleanup (remove lit-html, old spa/, LitBridge, @legacy alias) | **Complete** |
|
||||
| 5 | Optional enhancements (tests, react-query, Storybook) | Not started |
|
||||
| 5 | Frontend CI + vitest unit/component tests + navbar → React (SPA shell) | **Complete** |
|
||||
|
||||
> **Phase 3 status:** All `window.Chart` / `window.L` / `window.QRCode` globals and the
|
||||
> `charts.js` helper script are gone. Charts now use **react-chartjs-2** (typed builders in
|
||||
@@ -199,13 +199,39 @@ Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `as
|
||||
- (Vendor script tags / `charts.js` / `build.js` vendor copy were already removed in Phase 3.)
|
||||
- Updated `AGENTS.md` with the React frontend conventions.
|
||||
|
||||
## Phase 5: Optional Enhancements
|
||||
## Phase 5: Frontend CI, Tests & SPA Shell — Complete
|
||||
|
||||
- Add `vitest` + `@testing-library/react` for component tests
|
||||
- Add Playwright for E2E browser tests
|
||||
- Consider `@tanstack/react-query` for data fetching
|
||||
- Consider moving navbar from Jinja2 to React (full SPA shell)
|
||||
- Add Storybook for component development
|
||||
- **Frontend CI job** (`.github/workflows/ci.yml`): `npm ci` → `tsc --noEmit` →
|
||||
`npm run test:frontend` → `npm run build` on every push/PR. Closes the gap where the
|
||||
~9k lines of TSX had no CI coverage (pre-commit is Python-only).
|
||||
- **vitest** (`vitest.config.ts`, jsdom env, `npm run test:frontend`):
|
||||
- `utils/charts.test.ts` — tier math (`routeQualityToTier`, `averageRouteTier`) and every
|
||||
chart builder (empty → null, dataset counts/labels/colors, stacked %, route-strip segments).
|
||||
- `utils/format.test.ts` — `parseAppDate`, `formatNumber`, `truncateKey`, `typeEmoji`,
|
||||
`extractFirstEmoji`, `getNodeEmoji`, `formatRelativeTime`.
|
||||
- `components/Navbar.test.tsx` — feature-gated nav links, custom pages, OIDC/maintenance
|
||||
auth gating (rendered with `MemoryRouter` + `AppConfigProvider`).
|
||||
- `components/Announcements.test.tsx` — system/network banner rendering, ordering, dismiss
|
||||
+ sessionStorage persistence (covers behaviour that moved out of the Python suite).
|
||||
- **Navbar → React (full SPA shell)**:
|
||||
- New `components/Navbar.tsx`, `components/ThemeToggle.tsx`, `components/Announcements.tsx`,
|
||||
and `hooks/useNavItems.tsx` (shared feature-gated nav list used by desktop + mobile).
|
||||
- `main.tsx` now renders a single root; `App.tsx` renders `<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
|
||||
|
||||
|
||||
Generated
+1127
-1
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -1,17 +1,27 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev": "vite --config vite.config.ts"
|
||||
"dev": "vite --config vite.config.ts",
|
||||
"test:frontend": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/leaflet": "^1.9.17",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^5.8",
|
||||
"vite": "^6"
|
||||
"vite": "^6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/ibm-plex-sans": "^5",
|
||||
|
||||
@@ -361,6 +361,8 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
|
||||
"locale_version": getattr(app.state, "locale_version", ""),
|
||||
"system_maintenance": app.state.system_maintenance,
|
||||
"spam_score_threshold": app.state.spam_score_threshold,
|
||||
"system_announcement": app.state.system_announcement,
|
||||
"network_announcement": app.state.network_announcement,
|
||||
}
|
||||
|
||||
role_names = {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "react-router";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Announcements } from "@/components/Announcements";
|
||||
import { HomePage } from "@/pages/Home";
|
||||
import { DashboardPage } from "@/pages/Dashboard";
|
||||
import { Nodes } from "@/pages/Nodes";
|
||||
@@ -33,21 +35,6 @@ function useNavActiveState() {
|
||||
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
document.querySelectorAll("[data-nav-link]").forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
let isActive = false;
|
||||
if (href === "/") {
|
||||
isActive = pathname === "/";
|
||||
} else if (href === "/nodes") {
|
||||
isActive = pathname.startsWith("/nodes");
|
||||
} else if (href) {
|
||||
isActive = pathname === href || pathname.startsWith(href + "/");
|
||||
}
|
||||
link.classList.toggle("active", isActive);
|
||||
});
|
||||
|
||||
const loader = document.getElementById("nav-loading");
|
||||
if (loader) loader.classList.add("hidden");
|
||||
|
||||
if (document.activeElement?.closest(".dropdown")) {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
@@ -266,10 +253,22 @@ function AppRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<Announcements />
|
||||
<main className="container mx-auto px-4 py-6 flex-1">
|
||||
<AppRoutes />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
<Shell />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
import { Announcements } from "@/components/Announcements";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import type { AppConfig } from "@/types/config";
|
||||
|
||||
function renderAnnouncements(config: AppConfig) {
|
||||
return render(
|
||||
<AppConfigProvider config={config}>
|
||||
<Announcements />
|
||||
</AppConfigProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("Announcements", () => {
|
||||
it("renders nothing when there are no announcements", () => {
|
||||
const { container } = renderAnnouncements(makeConfig());
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the system banner content as HTML", () => {
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({ system_announcement: "<strong>Outage</strong> at 22:00" }),
|
||||
);
|
||||
expect(container.querySelector("#system-banner")).not.toBeNull();
|
||||
expect(screen.getByText("Outage").tagName).toBe("STRONG");
|
||||
});
|
||||
|
||||
it("renders the network banner with a dismiss button", () => {
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({ network_announcement: "<p>Notice</p>" }),
|
||||
);
|
||||
expect(container.querySelector("#flash-banner")).not.toBeNull();
|
||||
expect(screen.getByLabelText("Dismiss")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render a dismiss control on the system banner", () => {
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({ system_announcement: "Heads up" }),
|
||||
);
|
||||
const banner = container.querySelector("#system-banner");
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner!.querySelector("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the system banner above the network banner", () => {
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({
|
||||
system_announcement: "System notice",
|
||||
network_announcement: "Network notice",
|
||||
}),
|
||||
);
|
||||
const system = container.querySelector("#system-banner");
|
||||
const network = container.querySelector("#flash-banner");
|
||||
expect(system).not.toBeNull();
|
||||
expect(network).not.toBeNull();
|
||||
// network follows system in document order
|
||||
expect(
|
||||
system!.compareDocumentPosition(network!) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dismisses the network banner and persists to sessionStorage", () => {
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({ network_announcement: "Notice" }),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Dismiss"));
|
||||
expect(container.querySelector("#flash-banner")).toBeNull();
|
||||
expect(sessionStorage.getItem("flash-banner-dismissed")).toBe("1");
|
||||
});
|
||||
|
||||
it("does not render a previously dismissed network banner", () => {
|
||||
sessionStorage.setItem("flash-banner-dismissed", "1");
|
||||
const { container } = renderAnnouncements(
|
||||
makeConfig({ network_announcement: "Notice" }),
|
||||
);
|
||||
expect(container.querySelector("#flash-banner")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
|
||||
export function Announcements() {
|
||||
const config = useAppConfig();
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
try {
|
||||
return sessionStorage.getItem("flash-banner-dismissed") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const system = config.system_announcement;
|
||||
const network = config.network_announcement;
|
||||
|
||||
const dismiss = () => {
|
||||
setDismissed(true);
|
||||
try {
|
||||
sessionStorage.setItem("flash-banner-dismissed", "1");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (!system && (!network || dismissed)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{system && (
|
||||
<div
|
||||
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 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{network && !dismissed && (
|
||||
<div
|
||||
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 }}
|
||||
/>
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
onClick={dismiss}
|
||||
className="btn btn-ghost btn-xs"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +1,21 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import {
|
||||
IconHome,
|
||||
IconDashboard,
|
||||
IconNodes,
|
||||
IconAdvertisements,
|
||||
IconMessages,
|
||||
IconPackets,
|
||||
IconMap,
|
||||
IconMembers,
|
||||
IconPage,
|
||||
IconChannel,
|
||||
IconPath,
|
||||
} from "@/components/icons";
|
||||
import { NavLink } from "react-router";
|
||||
|
||||
import { useNavItems } from "@/hooks/useNavItems";
|
||||
|
||||
export function MobileNav() {
|
||||
const { t } = useTranslation();
|
||||
const config = useAppConfig();
|
||||
const features = config.features ?? {};
|
||||
const customPages = config.custom_pages ?? [];
|
||||
|
||||
const items: { href: string; icon: React.ReactNode; label: string }[] = [
|
||||
{ href: "/", icon: <IconHome className="h-5 w-5" />, label: t("entities.home") },
|
||||
];
|
||||
|
||||
if (features.dashboard !== false)
|
||||
items.push({ href: "/dashboard", icon: <IconDashboard className="h-5 w-5 nav-icon-dashboard" />, label: t("entities.dashboard") });
|
||||
if (features.nodes !== false)
|
||||
items.push({ href: "/nodes", icon: <IconNodes className="h-5 w-5 nav-icon-nodes" />, label: t("entities.nodes") });
|
||||
if (features.advertisements !== false)
|
||||
items.push({ href: "/advertisements", icon: <IconAdvertisements className="h-5 w-5 nav-icon-adverts" />, label: t("entities.advertisements") });
|
||||
if (features.routes !== false)
|
||||
items.push({ href: "/routes", icon: <IconPath className="h-5 w-5 nav-icon-routes" />, label: t("entities.routes") });
|
||||
if (features.channels !== false)
|
||||
items.push({ href: "/channels", icon: <IconChannel className="h-5 w-5 nav-icon-channels" />, label: t("entities.channels") });
|
||||
if (features.messages !== false)
|
||||
items.push({ href: "/messages", icon: <IconMessages className="h-5 w-5 nav-icon-messages" />, label: t("entities.messages") });
|
||||
if (features.packets !== false)
|
||||
items.push({ href: "/packets", icon: <IconPackets className="h-5 w-5 nav-icon-packets" />, label: t("entities.packets") });
|
||||
if (features.map !== false)
|
||||
items.push({ href: "/map", icon: <IconMap className="h-5 w-5 nav-icon-map" />, label: t("entities.map") });
|
||||
if (features.members !== false)
|
||||
items.push({ href: "/members", icon: <IconMembers className="h-5 w-5 nav-icon-members" />, label: t("entities.members") });
|
||||
|
||||
if (features.pages !== false) {
|
||||
for (const page of customPages) {
|
||||
items.push({ href: page.url, icon: <IconPage className="h-5 w-5" />, label: page.title });
|
||||
}
|
||||
}
|
||||
const items = useNavItems("h-5 w-5");
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<a href={item.href} data-nav-link>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
end={item.end}
|
||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||
>
|
||||
{item.icon} {item.label}
|
||||
</a>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { makeConfig } from "@/test/makeConfig";
|
||||
import type { AppConfig } from "@/types/config";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderNavbar(config: AppConfig) {
|
||||
return render(
|
||||
<AppConfigProvider config={config}>
|
||||
<MemoryRouter>
|
||||
<Navbar />
|
||||
</MemoryRouter>
|
||||
</AppConfigProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Each nav label renders twice (desktop menu + mobile dropdown).
|
||||
const labelCount = (label: string) => screen.queryAllByText(label).length;
|
||||
|
||||
describe("Navbar feature gating", () => {
|
||||
it("renders all feature links when every feature is enabled", () => {
|
||||
renderNavbar(makeConfig());
|
||||
expect(labelCount("entities.home")).toBeGreaterThan(0);
|
||||
expect(labelCount("entities.dashboard")).toBeGreaterThan(0);
|
||||
expect(labelCount("entities.nodes")).toBeGreaterThan(0);
|
||||
expect(labelCount("entities.messages")).toBeGreaterThan(0);
|
||||
expect(labelCount("entities.map")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides links for disabled features", () => {
|
||||
renderNavbar(
|
||||
makeConfig({
|
||||
features: { dashboard: false, nodes: false, map: false },
|
||||
}),
|
||||
);
|
||||
expect(labelCount("entities.dashboard")).toBe(0);
|
||||
expect(labelCount("entities.nodes")).toBe(0);
|
||||
expect(labelCount("entities.map")).toBe(0);
|
||||
// Still-enabled features remain
|
||||
expect(labelCount("entities.messages")).toBeGreaterThan(0);
|
||||
expect(labelCount("entities.home")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows only Home when all features are off (maintenance)", () => {
|
||||
renderNavbar(
|
||||
makeConfig({
|
||||
system_maintenance: true,
|
||||
features: {
|
||||
dashboard: false,
|
||||
nodes: false,
|
||||
advertisements: false,
|
||||
routes: false,
|
||||
channels: false,
|
||||
messages: false,
|
||||
packets: false,
|
||||
map: false,
|
||||
members: false,
|
||||
pages: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(labelCount("entities.home")).toBeGreaterThan(0);
|
||||
expect(labelCount("entities.dashboard")).toBe(0);
|
||||
expect(labelCount("entities.nodes")).toBe(0);
|
||||
expect(labelCount("entities.messages")).toBe(0);
|
||||
});
|
||||
|
||||
it("renders custom pages when the pages feature is enabled", () => {
|
||||
renderNavbar(
|
||||
makeConfig({
|
||||
custom_pages: [
|
||||
{ slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(labelCount("About Us")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides custom pages when the pages feature is disabled", () => {
|
||||
renderNavbar(
|
||||
makeConfig({
|
||||
features: { pages: false },
|
||||
custom_pages: [
|
||||
{ slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(labelCount("About Us")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Navbar auth gating", () => {
|
||||
it("shows the login button when OIDC is enabled and not in maintenance", () => {
|
||||
renderNavbar(makeConfig({ oidc_enabled: true }));
|
||||
expect(labelCount("auth.login")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides auth when OIDC is disabled", () => {
|
||||
renderNavbar(makeConfig({ oidc_enabled: false }));
|
||||
expect(labelCount("auth.login")).toBe(0);
|
||||
});
|
||||
|
||||
it("hides auth in maintenance mode even when OIDC is enabled", () => {
|
||||
renderNavbar(
|
||||
makeConfig({ oidc_enabled: true, system_maintenance: true }),
|
||||
);
|
||||
expect(labelCount("auth.login")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NavLink } from "react-router";
|
||||
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useNavItems } from "@/hooks/useNavItems";
|
||||
import { AuthSection } from "@/components/AuthSection";
|
||||
import { MobileNav } from "@/components/MobileNav";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
|
||||
export function Navbar() {
|
||||
const config = useAppConfig();
|
||||
const items = useNavItems("h-4 w-4");
|
||||
const logoClass = `theme-logo${
|
||||
config.logo_invert_light ? " theme-logo--invert-light" : ""
|
||||
} h-6 w-6 mr-2`;
|
||||
|
||||
return (
|
||||
<div className="navbar bg-base-100 shadow-lg">
|
||||
<div className="navbar-start">
|
||||
<NavLink to="/" end className="btn btn-ghost text-xl">
|
||||
<img src={config.logo_url} alt={config.network_name} className={logoClass} />
|
||||
{config.network_name}
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="navbar-center hidden lg:flex">
|
||||
<ul className="menu menu-horizontal px-1">
|
||||
{items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
end={item.end}
|
||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||
>
|
||||
{item.icon} {item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="navbar-end gap-1 pr-2">
|
||||
<ThemeToggle />
|
||||
{config.oidc_enabled && !config.system_maintenance && <AuthSection />}
|
||||
<div className="dropdown dropdown-end lg:hidden">
|
||||
<div tabIndex={0} role="button" className="btn btn-ghost">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content menu z-50 p-2 shadow bg-base-100 rounded-box w-56 mt-3"
|
||||
>
|
||||
<MobileNav />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, type ChangeEvent } from "react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [isLight, setIsLight] = useState(
|
||||
() => document.documentElement.getAttribute("data-theme") === "light",
|
||||
);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const light = e.currentTarget.checked;
|
||||
const theme = light ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
try {
|
||||
localStorage.setItem("meshcore-theme", theme);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setIsLight(light);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="swap swap-rotate btn btn-ghost btn-circle btn-sm">
|
||||
<input type="checkbox" checked={isLight} onChange={handleChange} />
|
||||
{/* sun icon - shown in dark mode (click to switch to light) */}
|
||||
<svg
|
||||
className="swap-off fill-current w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.51,3.51,0,0,1,12,15.5Z" />
|
||||
</svg>
|
||||
{/* moon icon - shown in light mode (click to switch to dark) */}
|
||||
<svg
|
||||
className="swap-on fill-current w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
|
||||
</svg>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import {
|
||||
IconAdvertisements,
|
||||
IconChannel,
|
||||
IconDashboard,
|
||||
IconHome,
|
||||
IconMap,
|
||||
IconMembers,
|
||||
IconMessages,
|
||||
IconNodes,
|
||||
IconPackets,
|
||||
IconPage,
|
||||
IconPath,
|
||||
} from "@/components/icons";
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
export function useNavItems(sizeClass = "h-5 w-5"): NavItem[] {
|
||||
const { t } = useTranslation();
|
||||
const config = useAppConfig();
|
||||
const features = config.features ?? {};
|
||||
const customPages = config.custom_pages ?? [];
|
||||
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
href: "/",
|
||||
label: t("entities.home"),
|
||||
icon: <IconHome className={sizeClass} />,
|
||||
end: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (features.dashboard !== false)
|
||||
items.push({
|
||||
href: "/dashboard",
|
||||
label: t("entities.dashboard"),
|
||||
icon: <IconDashboard className={`${sizeClass} nav-icon-dashboard`} />,
|
||||
});
|
||||
if (features.nodes !== false)
|
||||
items.push({
|
||||
href: "/nodes",
|
||||
label: t("entities.nodes"),
|
||||
icon: <IconNodes className={`${sizeClass} nav-icon-nodes`} />,
|
||||
});
|
||||
if (features.advertisements !== false)
|
||||
items.push({
|
||||
href: "/advertisements",
|
||||
label: t("entities.advertisements"),
|
||||
icon: <IconAdvertisements className={`${sizeClass} nav-icon-adverts`} />,
|
||||
});
|
||||
if (features.routes !== false)
|
||||
items.push({
|
||||
href: "/routes",
|
||||
label: t("entities.routes"),
|
||||
icon: <IconPath className={`${sizeClass} nav-icon-routes`} />,
|
||||
});
|
||||
if (features.channels !== false)
|
||||
items.push({
|
||||
href: "/channels",
|
||||
label: t("entities.channels"),
|
||||
icon: <IconChannel className={`${sizeClass} nav-icon-channels`} />,
|
||||
});
|
||||
if (features.messages !== false)
|
||||
items.push({
|
||||
href: "/messages",
|
||||
label: t("entities.messages"),
|
||||
icon: <IconMessages className={`${sizeClass} nav-icon-messages`} />,
|
||||
});
|
||||
if (features.packets !== false)
|
||||
items.push({
|
||||
href: "/packets",
|
||||
label: t("entities.packets"),
|
||||
icon: <IconPackets className={`${sizeClass} nav-icon-packets`} />,
|
||||
});
|
||||
if (features.map !== false)
|
||||
items.push({
|
||||
href: "/map",
|
||||
label: t("entities.map"),
|
||||
icon: <IconMap className={`${sizeClass} nav-icon-map`} />,
|
||||
});
|
||||
if (features.members !== false)
|
||||
items.push({
|
||||
href: "/members",
|
||||
label: t("entities.members"),
|
||||
icon: <IconMembers className={`${sizeClass} nav-icon-members`} />,
|
||||
});
|
||||
|
||||
if (features.pages !== false) {
|
||||
for (const page of customPages) {
|
||||
items.push({
|
||||
href: page.url,
|
||||
label: page.title,
|
||||
icon: <IconPage className={sizeClass} />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { createRoot } from "react-dom/client";
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
import { initI18n } from "@/i18n";
|
||||
import { App } from "@/App";
|
||||
import { AuthSection } from "@/components/AuthSection";
|
||||
import { MobileNav } from "@/components/MobileNav";
|
||||
import type { AppConfig } from "@/types/config";
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -21,23 +19,13 @@ async function bootstrap() {
|
||||
const appContainer = document.getElementById("app");
|
||||
if (!appContainer) return;
|
||||
|
||||
const wrap = (ui: React.ReactNode) => (
|
||||
createRoot(appContainer).render(
|
||||
<StrictMode>
|
||||
<AppConfigProvider config={config}>{ui}</AppConfigProvider>
|
||||
</StrictMode>
|
||||
<AppConfigProvider config={config}>
|
||||
<App />
|
||||
</AppConfigProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
createRoot(appContainer).render(wrap(<App />));
|
||||
|
||||
const authContainer = document.getElementById("auth-section");
|
||||
if (authContainer) {
|
||||
createRoot(authContainer).render(wrap(<AuthSection />));
|
||||
}
|
||||
|
||||
const mobileNavContainer = document.getElementById("mobile-nav");
|
||||
if (mobileNavContainer) {
|
||||
createRoot(mobileNavContainer).render(wrap(<MobileNav />));
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AppConfig } from "@/types/config";
|
||||
|
||||
export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig {
|
||||
return {
|
||||
network_name: "TestNet",
|
||||
features: {},
|
||||
custom_pages: [],
|
||||
logo_url: "/logo.svg",
|
||||
version: "1.0.0",
|
||||
timezone: "UTC",
|
||||
timezone_iana: "UTC",
|
||||
default_theme: "dark",
|
||||
locale: "en",
|
||||
datetime_locale: "en-US",
|
||||
auto_refresh_seconds: 30,
|
||||
channel_labels: {},
|
||||
logo_invert_light: false,
|
||||
debug: false,
|
||||
locale_version: "",
|
||||
system_maintenance: false,
|
||||
spam_score_threshold: 0,
|
||||
oidc_enabled: false,
|
||||
user: null,
|
||||
roles: [],
|
||||
role_names: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -51,6 +51,8 @@ export interface AppConfig {
|
||||
user: OidcUser | null;
|
||||
roles: string[];
|
||||
role_names: Record<string, string>;
|
||||
system_announcement?: string | null;
|
||||
network_announcement?: string | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import {
|
||||
averageRouteTier,
|
||||
buildActivityChart,
|
||||
buildLineChart,
|
||||
buildRouteDetailStrip,
|
||||
buildRoutesTrend,
|
||||
buildStackedBar,
|
||||
ChartColors,
|
||||
routeQualityToTier,
|
||||
type ActivitySeries,
|
||||
type BreakdownBucket,
|
||||
type RouteOverviewEntry,
|
||||
} from "@/utils/charts";
|
||||
|
||||
const t = ((key: string) => key) as unknown as TFunction;
|
||||
|
||||
const dayLabel = (date: string) =>
|
||||
new Date(date).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
const series = (counts: number[]): ActivitySeries => ({
|
||||
data: counts.map((count, i) => ({
|
||||
date: `2026-02-0${i + 1}`,
|
||||
count,
|
||||
})),
|
||||
});
|
||||
|
||||
describe("routeQualityToTier", () => {
|
||||
it("maps clear/marginal through and everything else to failing", () => {
|
||||
expect(routeQualityToTier("clear")).toBe("clear");
|
||||
expect(routeQualityToTier("marginal")).toBe("marginal");
|
||||
expect(routeQualityToTier("failing")).toBe("failing");
|
||||
expect(routeQualityToTier("unknown")).toBe("failing");
|
||||
expect(routeQualityToTier("no_coverage")).toBe("failing");
|
||||
expect(routeQualityToTier(null)).toBe("failing");
|
||||
expect(routeQualityToTier(undefined)).toBe("failing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("averageRouteTier", () => {
|
||||
it("falls back to failing on empty/absent history", () => {
|
||||
expect(averageRouteTier(null)).toBe("failing");
|
||||
expect(averageRouteTier([])).toBe("failing");
|
||||
});
|
||||
|
||||
it("buckets the mean tier (clear=2, marginal=1, failing=0)", () => {
|
||||
const q = (quality: string) => [{ quality }];
|
||||
expect(averageRouteTier(q("clear"))).toBe("clear");
|
||||
expect(averageRouteTier(q("marginal"))).toBe("marginal");
|
||||
expect(averageRouteTier(q("failing"))).toBe("failing");
|
||||
|
||||
// mean (2+1)/2 = 1.5 -> clear
|
||||
expect(averageRouteTier([{ quality: "clear" }, { quality: "marginal" }])).toBe(
|
||||
"clear",
|
||||
);
|
||||
// mean (2+0)/2 = 1.0 -> marginal (>= 0.75)
|
||||
expect(averageRouteTier([{ quality: "clear" }, { quality: "failing" }])).toBe(
|
||||
"marginal",
|
||||
);
|
||||
// mean (1+0)/2 = 0.5 -> failing
|
||||
expect(
|
||||
averageRouteTier([{ quality: "marginal" }, { quality: "failing" }]),
|
||||
).toBe("failing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLineChart", () => {
|
||||
it("returns null for missing/empty data", () => {
|
||||
expect(buildLineChart(null, "L", "b", "bg", true)).toBeNull();
|
||||
expect(buildLineChart({ data: [] }, "L", "b", "bg", true)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a single filled dataset with formatted labels", () => {
|
||||
const cfg = buildLineChart(
|
||||
series([5, 10]),
|
||||
"Nodes",
|
||||
"border",
|
||||
"fill",
|
||||
true,
|
||||
);
|
||||
expect(cfg).not.toBeNull();
|
||||
expect(cfg!.data.labels).toEqual([dayLabel("2026-02-01"), dayLabel("2026-02-02")]);
|
||||
expect(cfg!.data.datasets).toHaveLength(1);
|
||||
const ds = cfg!.data.datasets[0] as { data: number[]; label: string; fill: boolean };
|
||||
expect(ds.label).toBe("Nodes");
|
||||
expect(ds.data).toEqual([5, 10]);
|
||||
expect(ds.fill).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildActivityChart", () => {
|
||||
it("returns null when both series are absent", () => {
|
||||
expect(buildActivityChart(null, null, t)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds one dataset when only adverts are provided", () => {
|
||||
const cfg = buildActivityChart(series([3, 4]), null, t);
|
||||
expect(cfg).not.toBeNull();
|
||||
expect(cfg!.data.datasets).toHaveLength(1);
|
||||
expect((cfg!.data.datasets[0] as { label: string }).label).toBe(
|
||||
"entities.advertisements",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds two datasets when both series are provided", () => {
|
||||
const cfg = buildActivityChart(series([3, 4]), series([1, 2]), t);
|
||||
expect(cfg).not.toBeNull();
|
||||
expect(cfg!.data.datasets).toHaveLength(2);
|
||||
const labels = cfg!.data.datasets.map((d) => (d as { label: string }).label);
|
||||
expect(labels).toEqual(["entities.advertisements", "entities.messages"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStackedBar", () => {
|
||||
it("returns null for empty buckets or zero total", () => {
|
||||
expect(buildStackedBar(null, ["red"])).toBeNull();
|
||||
expect(buildStackedBar([], ["red"])).toBeNull();
|
||||
const zero: BreakdownBucket[] = [
|
||||
{ label: "a", count: 0 },
|
||||
{ label: "b", count: 0 },
|
||||
];
|
||||
expect(buildStackedBar(zero, ["red"])).toBeNull();
|
||||
});
|
||||
|
||||
it("produces percentage datasets that sum to 100 with rawCount preserved", () => {
|
||||
const buckets: BreakdownBucket[] = [
|
||||
{ label: "a", count: 30 },
|
||||
{ label: "b", count: 70 },
|
||||
];
|
||||
const cfg = buildStackedBar(buckets, ["red", "blue"]);
|
||||
expect(cfg).not.toBeNull();
|
||||
const datasets = cfg!.data.datasets as {
|
||||
data: number[];
|
||||
rawCount: number;
|
||||
backgroundColor: string;
|
||||
}[];
|
||||
expect(datasets).toHaveLength(2);
|
||||
expect(datasets[0].data[0] + datasets[1].data[0]).toBeCloseTo(100);
|
||||
expect(datasets[0].rawCount).toBe(30);
|
||||
expect(datasets[1].rawCount).toBe(70);
|
||||
expect(datasets[0].backgroundColor).toBe("red");
|
||||
expect(datasets[1].backgroundColor).toBe("blue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRoutesTrend", () => {
|
||||
it("returns null for empty routes or routes without history", () => {
|
||||
expect(buildRoutesTrend(null, t)).toBeNull();
|
||||
expect(buildRoutesTrend([], t)).toBeNull();
|
||||
expect(
|
||||
buildRoutesTrend([{ from_label: "A", to_label: "B", history: [] }], t),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sorts by matched_count, uses categorical tiers, and colors by average tier", () => {
|
||||
const routes: RouteOverviewEntry[] = [
|
||||
{
|
||||
from_label: "A",
|
||||
to_label: "B",
|
||||
matched_count: 1,
|
||||
history: [
|
||||
{ date: "2026-02-01", quality: "clear", matched_count: 1 },
|
||||
{ date: "2026-02-02", quality: "clear", matched_count: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
from_label: "C",
|
||||
to_label: "D",
|
||||
matched_count: 9,
|
||||
history: [
|
||||
{ date: "2026-02-01", quality: "failing", matched_count: 9 },
|
||||
{ date: "2026-02-02", quality: "failing", matched_count: 9 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const cfg = buildRoutesTrend(routes, t);
|
||||
expect(cfg).not.toBeNull();
|
||||
const datasets = cfg!.data.datasets as unknown as {
|
||||
label: string;
|
||||
data: string[];
|
||||
borderColor: string;
|
||||
_matched: number[];
|
||||
}[];
|
||||
// Higher matched_count first
|
||||
expect(datasets[0].label).toBe("C \u2192 D");
|
||||
expect(datasets[0].data).toEqual(["failing", "failing"]);
|
||||
expect(datasets[0].borderColor).toBe(ChartColors.quality.failing);
|
||||
expect(datasets[0]._matched).toEqual([9, 9]);
|
||||
expect(datasets[1].data).toEqual(["clear", "clear"]);
|
||||
expect(datasets[1].borderColor).toBe(ChartColors.quality.clear);
|
||||
expect(cfg!.data.labels).toEqual([
|
||||
dayLabel("2026-02-01"),
|
||||
dayLabel("2026-02-02"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("respects maxRoutes", () => {
|
||||
const routes: RouteOverviewEntry[] = Array.from({ length: 8 }, (_, i) => ({
|
||||
from_label: `A${i}`,
|
||||
to_label: "B",
|
||||
matched_count: i,
|
||||
history: [{ date: "2026-02-01", quality: "clear", matched_count: i }],
|
||||
}));
|
||||
const cfg = buildRoutesTrend(routes, t, 6);
|
||||
expect(cfg!.data.datasets).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRouteDetailStrip", () => {
|
||||
it("returns null for missing/empty history", () => {
|
||||
expect(buildRouteDetailStrip(null, t)).toBeNull();
|
||||
expect(buildRouteDetailStrip({ data: [] }, t)).toBeNull();
|
||||
expect(buildRouteDetailStrip({}, t)).toBeNull();
|
||||
});
|
||||
|
||||
it("produces one colored segment per day", () => {
|
||||
const cfg = buildRouteDetailStrip(
|
||||
{
|
||||
data: [
|
||||
{ date: "2026-02-01", quality: "clear", matched_count: 5 },
|
||||
{ date: "2026-02-02", quality: "failing", matched_count: 2 },
|
||||
],
|
||||
},
|
||||
t,
|
||||
);
|
||||
expect(cfg).not.toBeNull();
|
||||
const datasets = cfg!.data.datasets as {
|
||||
data: number[];
|
||||
backgroundColor: string;
|
||||
_quality: string;
|
||||
_matched_count: number;
|
||||
}[];
|
||||
expect(datasets).toHaveLength(2);
|
||||
expect(datasets[0].data).toEqual([1]);
|
||||
expect(datasets[0].backgroundColor).toBe(ChartColors.quality.clear);
|
||||
expect(datasets[0]._quality).toBe("clear");
|
||||
expect(datasets[0]._matched_count).toBe(5);
|
||||
expect(datasets[1].backgroundColor).toBe(ChartColors.quality.failing);
|
||||
expect(datasets[1]._matched_count).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to no_coverage color for unknown quality", () => {
|
||||
const cfg = buildRouteDetailStrip(
|
||||
{ data: [{ date: "2026-02-01", quality: "weird", matched_count: 0 }] },
|
||||
t,
|
||||
);
|
||||
const ds = cfg!.data.datasets[0] as { backgroundColor: string };
|
||||
expect(ds.backgroundColor).toBe(ChartColors.quality.no_coverage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractFirstEmoji,
|
||||
formatNumber,
|
||||
formatRelativeTime,
|
||||
getNodeEmoji,
|
||||
parseAppDate,
|
||||
truncateKey,
|
||||
typeEmoji,
|
||||
} from "@/utils/format";
|
||||
|
||||
const fmt = (n: number) => new Intl.NumberFormat().format(n);
|
||||
|
||||
describe("parseAppDate", () => {
|
||||
it("returns null for empty/invalid input", () => {
|
||||
expect(parseAppDate(null)).toBeNull();
|
||||
expect(parseAppDate("")).toBeNull();
|
||||
expect(parseAppDate(" ")).toBeNull();
|
||||
expect(parseAppDate("not a date")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats naive datetimes as UTC", () => {
|
||||
const d = parseAppDate("2026-02-08 12:30:00");
|
||||
expect(d).not.toBeNull();
|
||||
expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00Z"));
|
||||
});
|
||||
|
||||
it("preserves explicit timezone offsets", () => {
|
||||
const d = parseAppDate("2026-02-08T12:30:00+02:00");
|
||||
expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00+02:00"));
|
||||
});
|
||||
|
||||
it("parses date-only strings", () => {
|
||||
const d = parseAppDate("2026-02-08");
|
||||
expect(d).not.toBeNull();
|
||||
expect(d!.getTime()).toBe(Date.parse("2026-02-08"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatNumber", () => {
|
||||
it("returns empty string for null/undefined/empty", () => {
|
||||
expect(formatNumber(null)).toBe("");
|
||||
expect(formatNumber(undefined)).toBe("");
|
||||
expect(formatNumber("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns the raw string for non-numeric input", () => {
|
||||
expect(formatNumber("abc")).toBe("abc");
|
||||
});
|
||||
|
||||
it("formats numbers with locale grouping", () => {
|
||||
expect(formatNumber(1234)).toBe(fmt(1234));
|
||||
expect(formatNumber("1234")).toBe(fmt(1234));
|
||||
expect(formatNumber(0)).toBe(fmt(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateKey", () => {
|
||||
it("returns '-' for empty input", () => {
|
||||
expect(truncateKey(null)).toBe("-");
|
||||
});
|
||||
|
||||
it("returns short keys unchanged", () => {
|
||||
expect(truncateKey("short")).toBe("short");
|
||||
});
|
||||
|
||||
it("truncates long keys with an ellipsis", () => {
|
||||
const key = "abcdefghijklmnopqrst";
|
||||
expect(truncateKey(key)).toBe("abcdefghijkl...");
|
||||
expect(truncateKey(key, 4)).toBe("abcd...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("typeEmoji", () => {
|
||||
it("maps node types to emoji (incl. inference from substrings)", () => {
|
||||
expect(typeEmoji("chat")).toBe("\u{1F4AC}");
|
||||
expect(typeEmoji("repeater")).toBe("\u{1F4E1}");
|
||||
expect(typeEmoji("room")).toBe("\u{1FAA7}");
|
||||
expect(typeEmoji("companion")).toBe("\u{1F4F1}");
|
||||
expect(typeEmoji("Chat Node")).toBe("\u{1F4AC}");
|
||||
expect(typeEmoji("My Repeater")).toBe("\u{1F4E1}");
|
||||
});
|
||||
|
||||
it("falls back to a pin for unknown/null types", () => {
|
||||
expect(typeEmoji(null)).toBe("\u{1F4CD}");
|
||||
expect(typeEmoji("sensor")).toBe("\u{1F4CD}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFirstEmoji", () => {
|
||||
it("returns null when there is no emoji", () => {
|
||||
expect(extractFirstEmoji(null)).toBeNull();
|
||||
expect(extractFirstEmoji("plain text")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts the first emoji", () => {
|
||||
expect(extractFirstEmoji("\u{1F525} hot node")).toBe("\u{1F525}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNodeEmoji", () => {
|
||||
it("prefers an emoji in the node name", () => {
|
||||
expect(getNodeEmoji("\u{1F680} Rocket", null)).toBe("\u{1F680}");
|
||||
});
|
||||
|
||||
it("infers from type/name when no name emoji", () => {
|
||||
expect(getNodeEmoji("Living Room", null)).toBe("\u{1FAA7}");
|
||||
expect(getNodeEmoji("X", "repeater")).toBe("\u{1F4E1}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime", () => {
|
||||
afterEach(() => {
|
||||
delete (window as { t?: unknown }).t;
|
||||
});
|
||||
|
||||
const withT = () => {
|
||||
window.t = (key: string) => key;
|
||||
};
|
||||
|
||||
const isoAgo = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("returns empty string for empty/invalid input", () => {
|
||||
withT();
|
||||
expect(formatRelativeTime(null)).toBe("");
|
||||
});
|
||||
|
||||
it("buckets elapsed time into relative labels", () => {
|
||||
withT();
|
||||
expect(formatRelativeTime(isoAgo(10 * 1000))).toBe("time.less_than_minute");
|
||||
expect(formatRelativeTime(isoAgo(5 * 60 * 1000))).toBe("time.minutes_ago");
|
||||
expect(formatRelativeTime(isoAgo(3 * 60 * 60 * 1000))).toBe("time.hours_ago");
|
||||
expect(formatRelativeTime(isoAgo(2 * 24 * 60 * 60 * 1000))).toBe(
|
||||
"time.days_ago",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -53,94 +53,8 @@
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ version }}">
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 flex flex-col">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="navbar-start">
|
||||
<a href="/" class="btn btn-ghost text-xl">
|
||||
<img src="{{ logo_url }}" alt="{{ network_name }}" class="theme-logo{% if logo_invert_light %} theme-logo--invert-light{% endif %} h-6 w-6 mr-2" />
|
||||
{{ network_name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> {{ t('entities.home') }}</a></li>
|
||||
{% if features.dashboard %}
|
||||
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> {{ t('entities.dashboard') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.nodes %}
|
||||
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> {{ t('entities.nodes') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.advertisements %}
|
||||
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> {{ t('entities.advertisements') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.routes %}
|
||||
<li><a href="/routes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-routes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" /></svg> {{ t('entities.routes') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.channels %}
|
||||
<li><a href="/channels" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-channels" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg> {{ t('entities.channels') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.messages %}
|
||||
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.packets %}
|
||||
<li><a href="/packets" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-packets" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg> {{ t('entities.packets') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.map %}
|
||||
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> {{ t('entities.map') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.members %}
|
||||
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> {{ t('entities.members') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.pages %}
|
||||
{% for page in custom_pages %}
|
||||
<li><a href="{{ page.url }}" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> {{ page.title }}</a></li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end gap-1 pr-2">
|
||||
<span id="nav-loading" class="loading loading-spinner loading-sm hidden"></span>
|
||||
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
|
||||
<input type="checkbox" id="theme-toggle" />
|
||||
<!-- sun icon - shown in dark mode (click to switch to light) -->
|
||||
<svg class="swap-off fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/></svg>
|
||||
<!-- moon icon - shown in light mode (click to switch to dark) -->
|
||||
<svg class="swap-on fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
|
||||
</label>
|
||||
{% if oidc_enabled and not system_maintenance %}
|
||||
<div id="auth-section"></div>
|
||||
{% endif %}
|
||||
<div class="dropdown dropdown-end lg:hidden">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</div>
|
||||
<ul id="mobile-nav" tabindex="0" class="dropdown-content menu z-50 p-2 shadow bg-base-100 rounded-box w-56 mt-3">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if system_announcement %}
|
||||
<div id="system-banner" class="alert alert-error rounded-none py-2 px-4 text-center text-sm">
|
||||
<div class="flash-banner-content">{{ system_announcement | safe }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if network_announcement %}
|
||||
<div id="flash-banner" class="alert alert-warning rounded-none py-2 px-4 text-center text-sm">
|
||||
<div class="flash-banner-content">{{ network_announcement | safe }}</div>
|
||||
<button aria-label="Dismiss" onclick="document.getElementById('flash-banner').style.display='none'; sessionStorage.setItem('flash-banner-dismissed','1')" class="btn btn-ghost btn-xs">×</button>
|
||||
</div>
|
||||
<script>
|
||||
if (sessionStorage.getItem('flash-banner-dismissed') === '1') {
|
||||
document.getElementById('flash-banner').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-6 flex-1" id="app">
|
||||
</main>
|
||||
<!-- React shell: navbar, announcements, and routed page content 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">
|
||||
@@ -191,22 +105,6 @@
|
||||
window.__APP_CONFIG__ = {{ config_json|safe }};
|
||||
</script>
|
||||
|
||||
<!-- Theme toggle initialization -->
|
||||
<script>
|
||||
(function() {
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
if (toggle) {
|
||||
var current = document.documentElement.getAttribute('data-theme');
|
||||
toggle.checked = current === 'light';
|
||||
toggle.addEventListener('change', function() {
|
||||
var theme = this.checked ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('meshcore-theme', theme);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- SPA Application (ES Module, built by Vite) -->
|
||||
{% if asset_app_js %}
|
||||
<script type="module" src="/static/dist/{{ asset_app_js }}"></script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Web dashboard test fixtures."""
|
||||
|
||||
from typing import Any, Generator
|
||||
import json
|
||||
from typing import Any, Generator, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -22,6 +23,22 @@ ALL_FEATURES_ENABLED = {
|
||||
}
|
||||
|
||||
|
||||
def get_app_config(html: str) -> dict[str, Any]:
|
||||
"""Extract the embedded ``window.__APP_CONFIG__`` object from SPA shell HTML.
|
||||
|
||||
The navbar, banners, and feature-gated nav are rendered client-side by React
|
||||
from this config, so web tests assert on the config rather than on
|
||||
server-rendered nav HTML.
|
||||
"""
|
||||
marker = "window.__APP_CONFIG__ = "
|
||||
start = html.index(marker) + len(marker)
|
||||
script_end = html.index("</script>", start)
|
||||
# Use the last ";" before </script> so semicolons inside JSON string values
|
||||
# (e.g. announcement HTML) don't truncate the object.
|
||||
end = html.rindex(";", start, script_end)
|
||||
return cast(dict[str, Any], json.loads(html[start:end]))
|
||||
|
||||
|
||||
class MockHttpClient:
|
||||
"""Mock HTTP client for testing web routes."""
|
||||
|
||||
|
||||
+42
-80
@@ -17,7 +17,7 @@ from meshcore_hub.web.app import (
|
||||
create_app,
|
||||
)
|
||||
|
||||
from .conftest import ALL_FEATURES_ENABLED, MockHttpClient
|
||||
from .conftest import ALL_FEATURES_ENABLED, MockHttpClient, get_app_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -330,7 +330,7 @@ class TestFlashBannerVisibility:
|
||||
def test_banner_present_when_announcement_set(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Banner HTML is present when network_announcement is set."""
|
||||
"""Banner content is exposed in the SPA config when network_announcement is set."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -340,23 +340,19 @@ class TestFlashBannerVisibility:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
assert 'id="flash-banner"' in html
|
||||
assert "Scheduled maintenance at 22:00" in html
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["network_announcement"]
|
||||
assert "Scheduled maintenance at 22:00" in config["network_announcement"]
|
||||
|
||||
def test_banner_absent_when_announcement_none(self, client: TestClient) -> None:
|
||||
"""Banner HTML is absent when network_announcement is not set."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
assert 'id="flash-banner"' not in html
|
||||
"""Banner content is absent from the config when network_announcement is not set."""
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert not config["network_announcement"]
|
||||
|
||||
def test_banner_absent_for_empty_string(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Banner is not shown when announcement is an empty string."""
|
||||
"""Banner is not exposed when announcement is an empty string."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -366,14 +362,13 @@ class TestFlashBannerVisibility:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'id="flash-banner"' not in response.text
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert not config["network_announcement"]
|
||||
|
||||
def test_banner_absent_for_whitespace_only(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Banner is not shown when announcement is whitespace-only."""
|
||||
"""Banner is not exposed when announcement is whitespace-only."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -383,16 +378,15 @@ class TestFlashBannerVisibility:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'id="flash-banner"' not in response.text
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert not config["network_announcement"]
|
||||
|
||||
|
||||
class TestFlashBannerMarkdown:
|
||||
"""Tests for Markdown rendering in the flash banner."""
|
||||
|
||||
def test_bold_rendered(self, mock_http_client: MockHttpClient) -> None:
|
||||
"""Markdown bold is rendered to <strong>."""
|
||||
"""Markdown bold is rendered to <strong> in the config content."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -402,12 +396,11 @@ class TestFlashBannerMarkdown:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "<strong>important</strong>" in response.text
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert "<strong>important</strong>" in config["network_announcement"]
|
||||
|
||||
def test_link_rendered(self, mock_http_client: MockHttpClient) -> None:
|
||||
"""Markdown link is rendered to <a> tag."""
|
||||
"""Markdown link is rendered to <a> tag in the config content."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -417,16 +410,18 @@ class TestFlashBannerMarkdown:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert '<a href="https://example.com">click here</a>' in response.text
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert (
|
||||
'<a href="https://example.com">click here</a>'
|
||||
in config["network_announcement"]
|
||||
)
|
||||
|
||||
def test_raw_html_passed_through(self, mock_http_client: MockHttpClient) -> None:
|
||||
"""Raw HTML in announcement is passed through by the Markdown library.
|
||||
|
||||
This is safe because the announcement source is an operator-controlled
|
||||
environment variable, not user input — same trust model as custom pages
|
||||
in pages.py.
|
||||
in pages.py. The React banner renders it via dangerouslySetInnerHTML.
|
||||
"""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
@@ -437,9 +432,8 @@ class TestFlashBannerMarkdown:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "<b>bold</b>" in response.text
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert "<b>bold</b>" in config["network_announcement"]
|
||||
|
||||
|
||||
class TestSystemAnnouncementBanner:
|
||||
@@ -448,7 +442,7 @@ class TestSystemAnnouncementBanner:
|
||||
def test_system_banner_present_when_set(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""System banner HTML is present and Markdown-rendered when set."""
|
||||
"""System banner content is exposed and Markdown-rendered in the config."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -458,18 +452,19 @@ class TestSystemAnnouncementBanner:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
html = client.get("/").text
|
||||
assert 'id="system-banner"' in html
|
||||
assert "<strong>Outage</strong> at 22:00" in html
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["system_announcement"]
|
||||
assert "<strong>Outage</strong> at 22:00" in config["system_announcement"]
|
||||
|
||||
def test_system_banner_absent_when_none(self, client: TestClient) -> None:
|
||||
"""System banner HTML is absent when not set."""
|
||||
assert 'id="system-banner"' not in client.get("/").text
|
||||
"""System banner content is absent from the config when not set."""
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert not config["system_announcement"]
|
||||
|
||||
def test_system_banner_absent_for_empty_string(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""System banner is not shown for an empty string."""
|
||||
"""System banner is not exposed for an empty string."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -479,43 +474,12 @@ class TestSystemAnnouncementBanner:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
assert 'id="system-banner"' not in client.get("/").text
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert not config["system_announcement"]
|
||||
|
||||
def test_system_banner_not_dismissable(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""System banner has no dismiss button or sessionStorage script."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
system_announcement="Heads up",
|
||||
features=ALL_FEATURES_ENABLED,
|
||||
)
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
html = client.get("/").text
|
||||
banner = html[html.index('id="system-banner"') :]
|
||||
banner = banner[: banner.index("</div>")]
|
||||
assert "Dismiss" not in banner
|
||||
assert "sessionStorage" not in banner
|
||||
|
||||
def test_system_banner_stacked_above_network_banner(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""System banner is rendered above the network announcement banner."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
system_announcement="System notice",
|
||||
network_announcement="Network notice",
|
||||
features=ALL_FEATURES_ENABLED,
|
||||
)
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
html = client.get("/").text
|
||||
assert html.index('id="system-banner"') < html.index('id="flash-banner"')
|
||||
# NOTE: system-banner stacking order and the absence of a dismiss control are
|
||||
# now rendering behaviour of the React <Announcements> component, covered by
|
||||
# the frontend test suite (components/Announcements.test.tsx).
|
||||
|
||||
|
||||
class TestSystemMaintenance:
|
||||
@@ -532,7 +496,7 @@ class TestSystemMaintenance:
|
||||
assert all(value is False for value in app.state.features.values())
|
||||
|
||||
def test_maintenance_nav_only_home(self, mock_http_client: MockHttpClient) -> None:
|
||||
"""Desktop nav contains only Home (no feature links) in maintenance."""
|
||||
"""Config exposes all features off in maintenance, so the React nav shows only Home."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
@@ -542,10 +506,8 @@ class TestSystemMaintenance:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
html = client.get("/dashboard").text
|
||||
assert 'href="/dashboard"' not in html
|
||||
assert 'href="/nodes"' not in html
|
||||
assert 'href="/messages"' not in html
|
||||
config = get_app_config(client.get("/dashboard").text)
|
||||
assert not any(config["features"].values())
|
||||
|
||||
def test_maintenance_flag_in_config_json(
|
||||
self, mock_http_client: MockHttpClient
|
||||
|
||||
@@ -25,27 +25,19 @@ class TestDashboardPage:
|
||||
response = client.get("/dashboard")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_dashboard_displays_stats(
|
||||
def test_dashboard_serves_spa_shell(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that dashboard page displays statistics."""
|
||||
response = client.get("/dashboard")
|
||||
# Check for stats from mock response
|
||||
assert response.status_code == 200
|
||||
# The mock returns total_nodes: 10, active_nodes: 5, etc.
|
||||
# These should be displayed in the page
|
||||
assert "10" in response.text # total_nodes
|
||||
assert "5" in response.text # active_nodes
|
||||
"""The dashboard route serves the SPA shell.
|
||||
|
||||
def test_dashboard_displays_message_counts(
|
||||
self, client: TestClient, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""Test that dashboard page displays message counts."""
|
||||
Dashboard statistics are fetched and rendered client-side by React from
|
||||
the API, so they are not present in the server-rendered shell; we assert
|
||||
the mount point and embedded config instead.
|
||||
"""
|
||||
response = client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
# Mock returns total_messages: 100, messages_today: 15
|
||||
assert "100" in response.text
|
||||
assert "15" in response.text
|
||||
assert 'id="app"' in response.text
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
|
||||
class TestDashboardPageAPIErrors:
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Tests for feature flags functionality."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
from tests.test_web.conftest import ALL_FEATURES_ENABLED, MockHttpClient
|
||||
from tests.test_web.conftest import (
|
||||
ALL_FEATURES_ENABLED,
|
||||
MockHttpClient,
|
||||
get_app_config,
|
||||
)
|
||||
|
||||
|
||||
class TestFeatureFlagsConfig:
|
||||
@@ -16,11 +18,7 @@ class TestFeatureFlagsConfig:
|
||||
"""All non-OIDC features should be enabled by default in config JSON."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
# Extract config JSON from script tag
|
||||
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
|
||||
end = html.index(";", start)
|
||||
config = json.loads(html[start:end])
|
||||
config = get_app_config(response.text)
|
||||
features = config["features"]
|
||||
non_oidc_features = {k: v for k, v in features.items() if k != "members"}
|
||||
assert all(
|
||||
@@ -30,10 +28,7 @@ class TestFeatureFlagsConfig:
|
||||
def test_features_dict_has_all_keys(self, client: TestClient) -> None:
|
||||
"""Features dict should have all 7 expected keys."""
|
||||
response = client.get("/")
|
||||
html = response.text
|
||||
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
|
||||
end = html.index(";", start)
|
||||
config = json.loads(html[start:end])
|
||||
config = get_app_config(response.text)
|
||||
features = config["features"]
|
||||
expected_keys = {
|
||||
"dashboard",
|
||||
@@ -49,45 +44,41 @@ class TestFeatureFlagsConfig:
|
||||
def test_disabled_features_in_config(self, client_no_features: TestClient) -> None:
|
||||
"""Disabled features should be false in config JSON."""
|
||||
response = client_no_features.get("/")
|
||||
html = response.text
|
||||
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
|
||||
end = html.index(";", start)
|
||||
config = json.loads(html[start:end])
|
||||
config = get_app_config(response.text)
|
||||
features = config["features"]
|
||||
assert all(not v for v in features.values()), "All features should be disabled"
|
||||
|
||||
|
||||
class TestFeatureFlagsNav:
|
||||
"""Test feature flags affect navigation."""
|
||||
"""Test feature flags affect navigation (via the SPA config the nav reads)."""
|
||||
|
||||
def test_enabled_features_show_nav_links(self, client: TestClient) -> None:
|
||||
"""Enabled features should show nav links."""
|
||||
response = client.get("/")
|
||||
html = response.text
|
||||
assert 'href="/dashboard"' in html
|
||||
assert 'href="/nodes"' in html
|
||||
assert 'href="/advertisements"' in html
|
||||
assert 'href="/messages"' in html
|
||||
assert 'href="/map"' in html
|
||||
"""Enabled features should be true in config so the React nav shows them."""
|
||||
config = get_app_config(client.get("/").text)
|
||||
features = config["features"]
|
||||
for key in ("dashboard", "nodes", "advertisements", "messages", "map"):
|
||||
assert features[key] is True
|
||||
|
||||
def test_disabled_features_hide_nav_links(
|
||||
self, client_no_features: TestClient
|
||||
) -> None:
|
||||
"""Disabled features should not show nav links."""
|
||||
response = client_no_features.get("/")
|
||||
html = response.text
|
||||
assert 'href="/dashboard"' not in html
|
||||
assert 'href="/nodes"' not in html
|
||||
assert 'href="/advertisements"' not in html
|
||||
assert 'href="/messages"' not in html
|
||||
assert 'href="/map"' not in html
|
||||
assert 'href="/members"' not in html
|
||||
"""Disabled features should be false in config so the React nav hides them."""
|
||||
config = get_app_config(client_no_features.get("/").text)
|
||||
features = config["features"]
|
||||
for key in (
|
||||
"dashboard",
|
||||
"nodes",
|
||||
"advertisements",
|
||||
"messages",
|
||||
"map",
|
||||
"members",
|
||||
):
|
||||
assert features[key] is False
|
||||
|
||||
def test_home_link_always_present(self, client_no_features: TestClient) -> None:
|
||||
"""Home link should always be present."""
|
||||
"""The SPA mount (where the always-present Home nav renders) is in the shell."""
|
||||
response = client_no_features.get("/")
|
||||
html = response.text
|
||||
assert 'href="/"' in html
|
||||
assert 'id="app"' in response.text
|
||||
|
||||
|
||||
class TestFeatureFlagsEndpoints:
|
||||
@@ -118,11 +109,7 @@ class TestFeatureFlagsEndpoints:
|
||||
self, client_no_features: TestClient
|
||||
) -> None:
|
||||
"""Custom pages should be empty in config when pages feature is disabled."""
|
||||
response = client_no_features.get("/")
|
||||
html = response.text
|
||||
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
|
||||
end = html.index(";", start)
|
||||
config = json.loads(html[start:end])
|
||||
config = get_app_config(client_no_features.get("/").text)
|
||||
assert config["custom_pages"] == []
|
||||
|
||||
|
||||
@@ -213,18 +200,18 @@ class TestPacketsFeatureFlag:
|
||||
) -> None:
|
||||
"""The packets nav link is absent when the feature is off."""
|
||||
client = self._make_app(mock_http_client, packets=False)
|
||||
html = client.get("/").text
|
||||
assert 'href="/packets"' not in html
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["features"]["packets"] is False
|
||||
# Messages still shows (ordering sanity)
|
||||
assert 'href="/messages"' in html
|
||||
assert config["features"]["messages"] is True
|
||||
|
||||
def test_packets_nav_shown_when_enabled(
|
||||
self, mock_http_client: MockHttpClient
|
||||
) -> None:
|
||||
"""The packets nav link appears when the feature is on."""
|
||||
client = self._make_app(mock_http_client, packets=True)
|
||||
html = client.get("/").text
|
||||
assert 'href="/packets"' in html
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["features"]["packets"] is True
|
||||
|
||||
def test_packets_enabled_by_default_in_settings(self) -> None:
|
||||
"""The declared default for feature_packets is True (env-independent)."""
|
||||
@@ -266,11 +253,10 @@ class TestFeatureFlagsIndividual:
|
||||
def test_disable_map_only(self, _make_client) -> None:
|
||||
"""Disabling only map should hide map but show others."""
|
||||
client = _make_client("map")
|
||||
response = client.get("/")
|
||||
html = response.text
|
||||
assert 'href="/map"' not in html
|
||||
assert 'href="/dashboard"' in html
|
||||
assert 'href="/nodes"' in html
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["features"]["map"] is False
|
||||
assert config["features"]["dashboard"] is True
|
||||
assert config["features"]["nodes"] is True
|
||||
|
||||
# Map data endpoint should 404
|
||||
response = client.get("/map/data")
|
||||
@@ -279,11 +265,10 @@ class TestFeatureFlagsIndividual:
|
||||
def test_disable_dashboard_only(self, _make_client) -> None:
|
||||
"""Disabling only dashboard should hide dashboard but show others."""
|
||||
client = _make_client("dashboard")
|
||||
response = client.get("/")
|
||||
html = response.text
|
||||
assert 'href="/dashboard"' not in html
|
||||
assert 'href="/nodes"' in html
|
||||
assert 'href="/map"' in html
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["features"]["dashboard"] is False
|
||||
assert config["features"]["nodes"] is True
|
||||
assert config["features"]["map"] is True
|
||||
|
||||
|
||||
class TestDashboardAutoDisable:
|
||||
@@ -310,12 +295,7 @@ class TestDashboardAutoDisable:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
response = client.get("/")
|
||||
html = response.text
|
||||
assert 'href="/dashboard"' not in html
|
||||
|
||||
# Check config JSON also reflects it
|
||||
config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["features"]["dashboard"] is False
|
||||
|
||||
def test_map_auto_disabled_when_nodes_off(
|
||||
@@ -339,12 +319,7 @@ class TestDashboardAutoDisable:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
response = client.get("/")
|
||||
html = response.text
|
||||
assert 'href="/map"' not in html
|
||||
|
||||
# Check config JSON also reflects it
|
||||
config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["features"]["map"] is False
|
||||
|
||||
# Map data endpoint should 404
|
||||
@@ -372,5 +347,5 @@ class TestDashboardAutoDisable:
|
||||
app.state.http_client = mock_http_client
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
response = client.get("/")
|
||||
assert 'href="/dashboard"' in response.text
|
||||
config = get_app_config(client.get("/").text)
|
||||
assert config["features"]["dashboard"] is True
|
||||
|
||||
@@ -76,13 +76,6 @@ class TestHomePage:
|
||||
response = client.get("/")
|
||||
assert "discord.gg/test" in response.text
|
||||
|
||||
def test_home_contains_navigation(self, client: TestClient) -> None:
|
||||
"""Test that home page contains navigation links."""
|
||||
response = client.get("/")
|
||||
assert 'href="/"' in response.text
|
||||
assert 'href="/nodes"' in response.text
|
||||
assert 'href="/messages"' in response.text
|
||||
|
||||
def test_home_contains_spa_mount(self, client: TestClient) -> None:
|
||||
"""Test that home page renders the React SPA mount point."""
|
||||
response = client.get("/")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for custom pages functionality (SPA)."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
@@ -10,6 +9,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meshcore_hub.web.pages import CustomPage, PageLoader
|
||||
from tests.test_web.conftest import get_app_config
|
||||
|
||||
|
||||
class TestCustomPage:
|
||||
@@ -484,32 +484,26 @@ Here are some answers.
|
||||
assert "Frequently Asked Questions" in data["content_html"]
|
||||
|
||||
def test_pages_in_navigation(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that custom pages appear in navigation."""
|
||||
"""Test that custom pages are exposed for the React navigation."""
|
||||
response = client_with_pages.get("/")
|
||||
assert response.status_code == 200
|
||||
# Check for navigation links
|
||||
assert 'href="/pages/about"' in response.text
|
||||
assert 'href="/pages/faq"' in response.text
|
||||
config = get_app_config(response.text)
|
||||
urls = [p["url"] for p in config["custom_pages"]]
|
||||
assert "/pages/about" in urls
|
||||
assert "/pages/faq" in urls
|
||||
|
||||
def test_pages_sorted_in_navigation(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that pages are sorted by menu_order in navigation."""
|
||||
"""Test that pages are sorted by menu_order for the React navigation."""
|
||||
response = client_with_pages.get("/")
|
||||
assert response.status_code == 200
|
||||
config = get_app_config(response.text)
|
||||
urls = [p["url"] for p in config["custom_pages"]]
|
||||
# About (order 10) should appear before FAQ (order 20)
|
||||
about_pos = response.text.find('href="/pages/about"')
|
||||
faq_pos = response.text.find('href="/pages/faq"')
|
||||
assert about_pos < faq_pos
|
||||
assert urls.index("/pages/about") < urls.index("/pages/faq")
|
||||
|
||||
def test_pages_in_config(self, client_with_pages: TestClient) -> None:
|
||||
"""Test that custom pages are included in SPA config."""
|
||||
response = client_with_pages.get("/")
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
config = get_app_config(client_with_pages.get("/").text)
|
||||
custom_pages = config["custom_pages"]
|
||||
assert len(custom_pages) == 2
|
||||
slugs = [p["slug"] for p in custom_pages]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const SPA_REACT = resolve(
|
||||
__dirname,
|
||||
"src/meshcore_hub/web/static/js/spa-react",
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": SPA_REACT,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/meshcore_hub/web/static/js/spa-react/**/*.test.{ts,tsx}"],
|
||||
setupFiles: ["src/meshcore_hub/web/static/js/spa-react/test/setup.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user