From c6a47525e74c674e621d479cc4e8e7c00b69eba7 Mon Sep 17 00:00:00 2001 From: xishell <26227374+xishell@users.noreply.github.com> Date: Tue, 5 May 2026 10:58:17 +0200 Subject: [PATCH 1/2] test(frontend): add vitest sutie with coverage parity to backend --- frontend/.gitignore | 3 + frontend/README.md | 39 ++++- frontend/src/__tests__/App.test.tsx | 22 +++ frontend/src/__tests__/HarborMap.test.tsx | 83 +++++++++ .../__tests__/ActivityLogPanel.test.tsx | 132 +++++++++++++++ .../components/__tests__/AuthDialog.test.tsx | 150 +++++++++++++++++ .../__tests__/BerthDetailPanel.test.tsx | 116 +++++++++++++ .../__tests__/DashboardLayoutContext.test.tsx | 90 ++++++++++ .../src/components/__tests__/Footer.test.tsx | 13 ++ .../__tests__/HarborMasterOverview.test.tsx | 64 +++++++ .../__tests__/HarborOverview.test.tsx | 90 ++++++++++ .../src/components/__tests__/Header.test.tsx | 88 ++++++++++ .../components/__tests__/MainLayout.test.tsx | 88 ++++++++++ .../components/__tests__/MapLegend.test.tsx | 25 +++ .../components/__tests__/NorthArrow.test.tsx | 10 ++ .../__tests__/NotificationSettings.test.tsx | 80 +++++++++ .../components/__tests__/SideMenu.test.tsx | 65 +++++++ .../components/__tests__/sharedUi.test.tsx | 94 +++++++++++ .../hooks/__tests__/useBerthDetail.test.ts | 75 +++++++++ .../hooks/__tests__/useBerthsStream.test.ts | 85 ++++++++++ frontend/src/hooks/__tests__/useNow.test.ts | 48 ++++++ frontend/src/lib/__tests__/freshness.test.ts | 44 +++++ frontend/src/lib/__tests__/marinas.test.ts | 26 +++ frontend/src/lib/__tests__/utils.test.ts | 24 +++ .../src/pages/__tests__/Dashboard.test.tsx | 35 ++++ .../src/pages/__tests__/Settings.test.tsx | 158 ++++++++++++++++++ frontend/src/test/Dashboard.test.tsx | 18 -- frontend/src/test/eventSource.ts | 70 ++++++++ frontend/src/test/helpers.tsx | 154 +++++++++++++++++ frontend/src/test/setup.ts | 67 +++++++- frontend/vitest.config.ts | 24 +++ 31 files changed, 2051 insertions(+), 29 deletions(-) create mode 100644 frontend/src/__tests__/App.test.tsx create mode 100644 frontend/src/__tests__/HarborMap.test.tsx create mode 100644 frontend/src/components/__tests__/ActivityLogPanel.test.tsx create mode 100644 frontend/src/components/__tests__/AuthDialog.test.tsx create mode 100644 frontend/src/components/__tests__/BerthDetailPanel.test.tsx create mode 100644 frontend/src/components/__tests__/DashboardLayoutContext.test.tsx create mode 100644 frontend/src/components/__tests__/Footer.test.tsx create mode 100644 frontend/src/components/__tests__/HarborMasterOverview.test.tsx create mode 100644 frontend/src/components/__tests__/HarborOverview.test.tsx create mode 100644 frontend/src/components/__tests__/Header.test.tsx create mode 100644 frontend/src/components/__tests__/MainLayout.test.tsx create mode 100644 frontend/src/components/__tests__/MapLegend.test.tsx create mode 100644 frontend/src/components/__tests__/NorthArrow.test.tsx create mode 100644 frontend/src/components/__tests__/NotificationSettings.test.tsx create mode 100644 frontend/src/components/__tests__/SideMenu.test.tsx create mode 100644 frontend/src/components/__tests__/sharedUi.test.tsx create mode 100644 frontend/src/hooks/__tests__/useBerthDetail.test.ts create mode 100644 frontend/src/hooks/__tests__/useBerthsStream.test.ts create mode 100644 frontend/src/hooks/__tests__/useNow.test.ts create mode 100644 frontend/src/lib/__tests__/freshness.test.ts create mode 100644 frontend/src/lib/__tests__/marinas.test.ts create mode 100644 frontend/src/lib/__tests__/utils.test.ts create mode 100644 frontend/src/pages/__tests__/Dashboard.test.tsx create mode 100644 frontend/src/pages/__tests__/Settings.test.tsx delete mode 100644 frontend/src/test/Dashboard.test.tsx create mode 100644 frontend/src/test/eventSource.ts create mode 100644 frontend/src/test/helpers.tsx diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..422b0da 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +coverage +junit.xml + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/README.md b/frontend/README.md index dd21339..a0f0509 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -46,14 +46,47 @@ Starts a Prism mock server from the OpenAPI spec and proxies to it automatically ## Frontend Tests -Frontend smoke tests use Vitest, React Testing Library, and Prism. +Frontend tests use Vitest, React Testing Library, and stubbed `fetch`/`EventSource`. +The structure mirrors the backend test suite: shared fixtures and factories live +alongside the test files and exercise the same flows the running app does. -The test command starts a Prism mock API from the OpenAPI specification at `docs/api/openapi.yml`, then runs the frontend test suite. +| Layer | Backend equivalent | Frontend equivalent | +| ------------ | --------------------------------- | --------------------------------------------------------- | +| Test runner | `pytest` | `vitest run` | +| HTTP harness | `httpx.AsyncClient` over ASGI app | `mockFetch` helper + `@testing-library/react` | +| Streams | aiomqtt test stubs | `FakeEventSource` mock with `emit`/`emitOpen`/`emitError` | +| Fixtures | `tests/conftest.py` | `src/test/setup.ts`, `src/test/helpers.tsx` | +| Factories | `tests/_helpers.py` | `makeBerth`, `makeUser`, `makeEvent`, `buildAuthContext` | +| Coverage | `pytest-cov` (`fail_under = 80`) | `@vitest/coverage-v8` (`thresholds.lines = 80`) | + +Tests live next to the source they exercise: + +``` +src/ + __tests__/ app + HarborMap entry tests + components/__tests__/ UI panels + layout chrome + hooks/__tests__/ data hooks (fake timers + mocked fetch/EventSource) + lib/__tests__/ pure helpers + pages/__tests__/ page components (Dashboard, Settings) + test/ shared setup, factories, EventSource mock +``` ### Run tests locally ```bash cd frontend bun install -bun run test +bun run test # full suite (boots Prism alongside vitest, mirrors CI) +bun run test:watch # vitest watch mode, no Prism +bun run test:cov # full suite with v8 coverage report ``` + +### Writing new tests + +- Reach for `renderWithAuthLayout` whenever a component uses `useOutletContext`. +- Use `mockFetch((url, init) => ...)` to replace the network layer; combine with + `jsonResponse` / `errorResponse` from `src/test/helpers.tsx`. +- For SSE-driven hooks, drive the `FakeEventSource` via `getLastEventSource()` + and call `emit("berth.update", payload)`. +- Keep factory builders in sync with `src/api-types.ts` — when a schema changes, + the type checker catches stale fixtures. diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx new file mode 100644 index 0000000..b64d9b9 --- /dev/null +++ b/frontend/src/__tests__/App.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { App } from "../App"; + +vi.mock("../components/layout/MainLayout", () => ({ + MainLayout: () =>
, +})); + +describe("App", () => { + test("renders MainLayout for a marina route", async () => { + window.history.pushState({}, "", "/saltsjobaden"); + render(); + expect(await screen.findByTestId("main-layout")).toBeInTheDocument(); + }); + + test("redirects root path to /saltsjobaden", async () => { + window.history.pushState({}, "", "/"); + render(); + await screen.findByTestId("main-layout"); + expect(window.location.pathname).toBe("/saltsjobaden"); + }); +}); diff --git a/frontend/src/__tests__/HarborMap.test.tsx b/frontend/src/__tests__/HarborMap.test.tsx new file mode 100644 index 0000000..39309d1 --- /dev/null +++ b/frontend/src/__tests__/HarborMap.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Outlet, Route, Routes } from "react-router-dom"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + buildAuthContext, + errorResponse, + jsonResponse, + makeBerth, + makeUser, + mockFetch, +} from "../test/helpers"; + +vi.mock("../svgMap", () => ({ + SvgMap: ({ onBerthClickCB }: { onBerthClickCB?: (id: string) => void }) => ( + + ), +})); + +vi.mock("../svg", () => ({ + mapBerthIds: new Set(["B1", "B2"]), + berthSlots: {}, +})); + +import { DashboardLayoutProvider } from "../components/layout/DashboardLayoutContext"; +import { HarborMap } from "../HarborMap"; + +function renderHarborMap( + authOverrides: Parameters[0] = {}, +) { + const ctx = buildAuthContext(authOverrides); + return render( + + + + }> + } /> + + + + , + ); +} + +describe("HarborMap", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("visitor view renders HarborOverview with snapshot data", async () => { + mockFetch(() => jsonResponse([makeBerth({ berth_id: "B1" })])); + renderHarborMap(); + expect(await screen.findByText("Harbor Overview")).toBeInTheDocument(); + }); + + test("harbormaster view renders Harbor Master HUD", async () => { + mockFetch(() => jsonResponse([makeBerth({ berth_id: "B1" })])); + renderHarborMap({ user: makeUser({ role: "harbormaster" }) }); + expect(await screen.findByText("Harbor Master HUD")).toBeInTheDocument(); + }); + + test("clicking a berth opens the detail panel", async () => { + mockFetch((url) => { + if (url.endsWith("/api/berths")) + return jsonResponse([makeBerth({ berth_id: "B1" })]); + if (url.includes("/api/berths/B1")) + return jsonResponse(makeBerth({ berth_id: "B1", label: "1" })); + return errorResponse(404); + }); + const user = userEvent.setup(); + renderHarborMap({ user: makeUser({ role: "boat_owner" }) }); + await user.click(await screen.findByTestId("svg-map")); + await waitFor(() => + expect(screen.getByText("Berth Detail")).toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/src/components/__tests__/ActivityLogPanel.test.tsx b/frontend/src/components/__tests__/ActivityLogPanel.test.tsx new file mode 100644 index 0000000..c5eb890 --- /dev/null +++ b/frontend/src/components/__tests__/ActivityLogPanel.test.tsx @@ -0,0 +1,132 @@ +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + jsonResponse, + makeBerth, + makeUser, + mockFetch, + renderWithAuthLayout, +} from "../../test/helpers"; +import { ActivityLogPanel } from "../ActivityLogPanel"; + +type Berth = ReturnType; + +describe("ActivityLogPanel", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("shows empty state when no events", async () => { + mockFetch(() => jsonResponse([])); + renderWithAuthLayout(, { + auth: { user: makeUser({ role: "harbormaster" }), token: "t" }, + }); + expect( + await screen.findByText(/waiting for activity/i), + ).toBeInTheDocument(); + }); + + test("hydrates events from per-user localStorage key", async () => { + const user = makeUser({ user_id: "u123", role: "harbormaster" }); + localStorage.setItem( + `dockpulse_activity_log:${user.user_id}`, + JSON.stringify([ + { + id: "e1", + timestamp: new Date("2026-05-05T10:00:00Z").toISOString(), + type: "status_change", + berthId: "B1", + berthLabel: "1", + details: "Status changed from free to occupied", + status: "occupied", + }, + ]), + ); + mockFetch(() => jsonResponse([])); + renderWithAuthLayout(, { + auth: { user, token: "t" }, + }); + expect( + await screen.findByText(/Status changed from free to occupied/), + ).toBeInTheDocument(); + }); + + test("filters events by type", async () => { + const user = makeUser({ user_id: "u123", role: "harbormaster" }); + localStorage.setItem( + `dockpulse_activity_log:${user.user_id}`, + JSON.stringify([ + { + id: "e1", + timestamp: new Date().toISOString(), + type: "status_change", + berthId: "B1", + berthLabel: "1", + details: "Status changed", + status: "free", + }, + { + id: "e2", + timestamp: new Date().toISOString(), + type: "owner_assignment", + berthId: "B2", + berthLabel: "2", + details: "New owner assigned to berth", + }, + ]), + ); + mockFetch(() => jsonResponse([])); + const userEvt = userEvent.setup(); + renderWithAuthLayout(, { + auth: { user, token: "t" }, + }); + await screen.findByText("Status changed"); + await userEvt.click(screen.getByRole("button", { name: /^owners$/i })); + expect(screen.queryByText("Status changed")).not.toBeInTheDocument(); + expect(screen.getByText("New owner assigned to berth")).toBeInTheDocument(); + }); + + test("synthesizes events from berth status changes", async () => { + mockFetch(() => jsonResponse([])); + const user = makeUser({ user_id: "u123", role: "harbormaster" }); + let setBerths!: (b: Berth[]) => void; + function Harness() { + const [berths, set] = useState([ + makeBerth({ berth_id: "B1", status: "free" }), + ]); + setBerths = set; + return ; + } + renderWithAuthLayout(, { auth: { user, token: "t" } }); + await screen.findByText(/waiting for activity/i); + act(() => setBerths([makeBerth({ berth_id: "B1", status: "occupied" })])); + await waitFor(() => + expect( + screen.getByText("Status changed from free to occupied"), + ).toBeInTheDocument(), + ); + }); + + test("close fires callback", async () => { + mockFetch(() => jsonResponse([])); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithAuthLayout( + , + { auth: { user: makeUser({ role: "harbormaster" }), token: "t" } }, + ); + await screen.findByText(/waiting for activity/i); + const buttons = screen.getAllByRole("button"); + const closeBtn = buttons.find( + (b) => + !["all", "status", "owners"].includes( + b.textContent?.toLowerCase() ?? "", + ), + ); + if (!closeBtn) throw new Error("close button not found"); + await user.click(closeBtn); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/__tests__/AuthDialog.test.tsx b/frontend/src/components/__tests__/AuthDialog.test.tsx new file mode 100644 index 0000000..3a2414c --- /dev/null +++ b/frontend/src/components/__tests__/AuthDialog.test.tsx @@ -0,0 +1,150 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { errorResponse, jsonResponse, mockFetch } from "../../test/helpers"; +import { AuthDialog } from "../layout/AuthDialog"; + +function renderDialog( + overrides: Partial> = {}, +) { + const props = { + open: true, + onOpenChange: vi.fn(), + onAuthSuccess: vi.fn(), + ...overrides, + }; + return { props, ...render() }; +} + +describe("AuthDialog", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("renders login tab by default", () => { + renderDialog(); + expect(screen.getByText(/welcome back/i)).toBeInTheDocument(); + }); + + test("switches to signup tab", async () => { + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + expect( + screen.getByRole("heading", { name: /create account/i }), + ).toBeInTheDocument(); + }); + + test("submit disabled until login fields filled", async () => { + renderDialog(); + const submit = screen.getByRole("button", { name: /sign in/i }); + expect(submit).toBeDisabled(); + }); + + test("login flow calls onAuthSuccess with token", async () => { + mockFetch((url) => { + if (url.endsWith("/api/auth/login")) + return jsonResponse({ access_token: "tok-123" }); + return errorResponse(404); + }); + const onAuthSuccess = vi.fn(); + const user = userEvent.setup(); + renderDialog({ onAuthSuccess }); + await user.type(screen.getByLabelText(/^email$/i), "olle@example.com"); + await user.type(screen.getByLabelText(/^password$/i), "secret"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + await waitFor(() => + expect(onAuthSuccess).toHaveBeenCalledWith("tok-123", { + email: "olle@example.com", + }), + ); + }); + + test("login surfaces server error", async () => { + mockFetch((url) => { + if (url.endsWith("/api/auth/login")) + return errorResponse(401, { detail: "wrong password" }); + return errorResponse(404); + }); + const user = userEvent.setup(); + renderDialog(); + await user.type(screen.getByLabelText(/^email$/i), "olle@example.com"); + await user.type(screen.getByLabelText(/^password$/i), "secret"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByText(/wrong password/i)).toBeInTheDocument(); + }); + + test("signup blocks until passwords match", async () => { + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + await user.type(screen.getByLabelText(/^email$/i), "olle@example.com"); + await user.type(screen.getByLabelText(/first name/i), "Olle"); + await user.type(screen.getByLabelText(/last name/i), "Owner"); + await user.type(screen.getByLabelText(/^password$/i), "abcd"); + await user.type(screen.getByLabelText(/confirm password/i), "different"); + expect( + screen.getByRole("button", { name: /create account/i }), + ).toBeDisabled(); + expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument(); + }); + + test("signup calls register then auto-logs in", async () => { + const calls: string[] = []; + mockFetch((url) => { + calls.push(url); + if (url.endsWith("/api/auth/register")) return jsonResponse({}); + if (url.endsWith("/api/auth/login")) + return jsonResponse({ access_token: "tok-new" }); + return errorResponse(404); + }); + const onAuthSuccess = vi.fn(); + const user = userEvent.setup(); + renderDialog({ onAuthSuccess }); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + await user.type(screen.getByLabelText(/^email$/i), "ny@example.com"); + await user.type(screen.getByLabelText(/first name/i), "Nya"); + await user.type(screen.getByLabelText(/last name/i), "Skeppare"); + await user.type(screen.getByLabelText(/^password$/i), "password"); + await user.type(screen.getByLabelText(/confirm password/i), "password"); + await user.click(screen.getByRole("button", { name: /create account/i })); + await waitFor(() => expect(onAuthSuccess).toHaveBeenCalled()); + expect(calls).toContain("/api/auth/register"); + expect(calls).toContain("/api/auth/login"); + }); + + test("signup surfaces array-form 422 errors", async () => { + mockFetch((url) => { + if (url.endsWith("/api/auth/register")) + return errorResponse(422, { + detail: [{ loc: ["body", "email"], msg: "exists" }], + }); + return errorResponse(404); + }); + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + await user.type(screen.getByLabelText(/^email$/i), "ny@example.com"); + await user.type(screen.getByLabelText(/first name/i), "Nya"); + await user.type(screen.getByLabelText(/last name/i), "Skeppare"); + await user.type(screen.getByLabelText(/^password$/i), "password"); + await user.type(screen.getByLabelText(/confirm password/i), "password"); + await user.click(screen.getByRole("button", { name: /create account/i })); + expect(await screen.findByText(/email: exists/i)).toBeInTheDocument(); + }); + + test("login throws when token missing in response", async () => { + mockFetch((url) => { + if (url.endsWith("/api/auth/login")) return jsonResponse({}); + return errorResponse(404); + }); + const user = userEvent.setup(); + renderDialog(); + await user.type(screen.getByLabelText(/^email$/i), "olle@example.com"); + await user.type(screen.getByLabelText(/^password$/i), "secret"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + expect( + await screen.findByText(/no access token was returned/i), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/BerthDetailPanel.test.tsx b/frontend/src/components/__tests__/BerthDetailPanel.test.tsx new file mode 100644 index 0000000..5526472 --- /dev/null +++ b/frontend/src/components/__tests__/BerthDetailPanel.test.tsx @@ -0,0 +1,116 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + errorResponse, + jsonResponse, + makeBerth, + makeEvent, + makeUser, + mockFetch, + renderWithAuthLayout, +} from "../../test/helpers"; +import { BerthDetailPanel } from "../BerthDetailPanel"; + +describe("BerthDetailPanel", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("renders supplied live berth without fetching", async () => { + const fetchSpy = mockFetch(() => jsonResponse(makeBerth())); + const berth = makeBerth({ + berth_id: "B7", + label: "7", + status: "occupied", + }); + renderWithAuthLayout( + {}} berth={berth} />, + { + auth: { user: makeUser({ role: "boat_owner" }), token: "t" }, + }, + ); + expect(await screen.findByText("7")).toBeInTheDocument(); + expect(screen.getByText("occupied")).toBeInTheDocument(); + // boat owner does not load events list + const eventCalls = fetchSpy.mock.calls.filter((c) => + String(c[0]).includes("/events"), + ); + expect(eventCalls).toHaveLength(0); + }); + + test("fetches berth when no live data passed", async () => { + mockFetch(() => + jsonResponse(makeBerth({ berth_id: "B1", label: "1", status: "free" })), + ); + renderWithAuthLayout( + {}} />, + { auth: { user: makeUser({ role: "boat_owner" }), token: "t" } }, + ); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(screen.getByText("free")).toBeInTheDocument(); + }); + + test("surfaces fetch error", async () => { + mockFetch(() => errorResponse(500, { detail: "boom" })); + renderWithAuthLayout( + {}} />, + { auth: { user: makeUser({ role: "boat_owner" }), token: "t" } }, + ); + expect(await screen.findByText(/Error:/)).toBeInTheDocument(); + }); + + test("harbormaster sees recent events list", async () => { + mockFetch((url) => { + if (url.includes("/events")) + return jsonResponse([ + makeEvent({ event_id: "e1", event_type: "occupied" }), + makeEvent({ event_id: "e2", event_type: "freed" }), + ]); + return jsonResponse(makeBerth({ berth_id: "B1" })); + }); + renderWithAuthLayout( + {}} + berth={makeBerth({ berth_id: "B1" })} + />, + { auth: { user: makeUser({ role: "harbormaster" }), token: "t" } }, + ); + expect(await screen.findByText("Recent Activity")).toBeInTheDocument(); + expect(await screen.findByText("occupied")).toBeInTheDocument(); + expect(await screen.findByText("freed")).toBeInTheDocument(); + }); + + test("close button fires callback after timeout", async () => { + mockFetch(() => jsonResponse(makeBerth({ berth_id: "B1" }))); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithAuthLayout( + , + { auth: { user: makeUser({ role: "boat_owner" }), token: "t" } }, + ); + await user.click(screen.getByLabelText(/close panel/i)); + await vi.waitFor(() => expect(onClose).toHaveBeenCalled(), { + timeout: 1500, + }); + }); + + test("renders battery bar when battery_pct provided", async () => { + mockFetch(() => jsonResponse(makeBerth({ battery_pct: 15 }))); + renderWithAuthLayout( + {}} + berth={makeBerth({ berth_id: "B1", battery_pct: 15 })} + />, + { auth: { user: makeUser({ role: "boat_owner" }), token: "t" } }, + ); + expect(await screen.findByText("Node Battery")).toBeInTheDocument(); + expect(await screen.findByText("15%")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/DashboardLayoutContext.test.tsx b/frontend/src/components/__tests__/DashboardLayoutContext.test.tsx new file mode 100644 index 0000000..fd2bef6 --- /dev/null +++ b/frontend/src/components/__tests__/DashboardLayoutContext.test.tsx @@ -0,0 +1,90 @@ +import { act, render, renderHook } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, test } from "vitest"; +import { + DashboardLayoutProvider, + useDashboardLayout, +} from "../layout/DashboardLayoutContext"; + +function wrap(userRole?: string) { + return ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); +} + +describe("DashboardLayoutContext", () => { + test("throws when used outside provider", () => { + // suppress react error boundary noise + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderHook(() => useDashboardLayout())).toThrow( + /must be used within a DashboardLayoutProvider/, + ); + spy.mockRestore(); + }); + + test("toggleOverview opens overview and closes activity log", () => { + const { result } = renderHook(() => useDashboardLayout(), { + wrapper: wrap(), + }); + act(() => result.current.setIsActivityLogOpen(true)); + expect(result.current.isActivityLogOpen).toBe(true); + act(() => result.current.toggleOverview()); + expect(result.current.isOverviewOpen).toBe(true); + expect(result.current.isActivityLogOpen).toBe(false); + }); + + test("toggleActivityLog opens activity log and closes overview", () => { + const { result } = renderHook(() => useDashboardLayout(), { + wrapper: wrap(), + }); + act(() => result.current.setIsOverviewOpen(true)); + act(() => result.current.toggleActivityLog()); + expect(result.current.isActivityLogOpen).toBe(true); + expect(result.current.isOverviewOpen).toBe(false); + }); + + test("closeAllPanels resets both panels", () => { + const { result } = renderHook(() => useDashboardLayout(), { + wrapper: wrap(), + }); + act(() => result.current.setIsOverviewOpen(true)); + act(() => result.current.setIsActivityLogOpen(true)); + act(() => result.current.closeAllPanels()); + expect(result.current.isOverviewOpen).toBe(false); + expect(result.current.isActivityLogOpen).toBe(false); + }); + + test("sidebar offset depends on harbormaster role and expansion", () => { + const { result, rerender } = renderHook(() => useDashboardLayout(), { + wrapper: wrap("harbormaster"), + }); + expect(result.current.sidebarOffset).toBe(112); + act(() => result.current.setIsMenuExpanded(true)); + expect(result.current.sidebarOffset).toBe(288); + rerender(); + }); + + test("non-harbormaster always gets default offset", () => { + const { result } = renderHook(() => useDashboardLayout(), { + wrapper: wrap("boat_owner"), + }); + expect(result.current.sidebarOffset).toBe(16); + }); + + test("provider renders children", () => { + const { getByText } = render( + + + child + + , + ); + expect(getByText("child")).toBeInTheDocument(); + }); +}); + +import { vi } from "vitest"; diff --git a/frontend/src/components/__tests__/Footer.test.tsx b/frontend/src/components/__tests__/Footer.test.tsx new file mode 100644 index 0000000..ceb6696 --- /dev/null +++ b/frontend/src/components/__tests__/Footer.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { Footer } from "../layout/Footer"; + +describe("Footer", () => { + test("renders current year and tagline", () => { + render(