diff --git a/.env b/.env
index e65f2cd..90d6284 100644
--- a/.env
+++ b/.env
@@ -1,2 +1,3 @@
VITE_APP_OUTPUT_DEBUG=true
-VITE_APP_APP_TITLE=RogueTech (Dev)
\ No newline at end of file
+VITE_APP_APP_TITLE=RogueTech (Dev)
+VITE_ENABLE_STATE_TEST=true
diff --git a/.env.production b/.env.production
index 9c00b8f..382f0d0 100644
--- a/.env.production
+++ b/.env.production
@@ -1,3 +1,5 @@
# .env.production
VITE_APP_OUTPUT_DEBUG=false
-VITE_APP_APP_TITLE=RogueTech
\ No newline at end of file
+VITE_APP_APP_TITLE=RogueTech
+VITE_BASE_URL=/
+VITE_ENABLE_STATE_TEST=true
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..c898bcb
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,43 @@
+name: Test
+
+on:
+ push:
+ branches: [main, test-platform]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ name: Vitest (Node ${{ matrix.node }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node: ['20', '22']
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node ${{ matrix.node }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: yarn
+
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: Typecheck test sources
+ run: npx tsc -p tsconfig.vitest.json --noEmit
+
+ - name: Run tests with coverage
+ run: yarn test:coverage
+
+ - name: Upload coverage report
+ if: matrix.node == '20'
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: coverage/
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
index a547bf3..430433b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@ dist
dist-ssr
*.local
+# Coverage output from @vitest/coverage-v8
+coverage
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..7be97bf
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,30 @@
+# Project Context & Agent Role
+
+You are an expert AI software engineer collaborating on a React + Vite codebase. We are currently embarking on a dedicated session to build a functioning, industry-standard test platform (test rig) for the project.
+
+## Primary Goal
+Establish a working test rig and implement comprehensive tests for all functionality currently present in the repository. The rig must load correctly and provide standard, readable output to the developer.
+
+## Rules and Constraints
+
+You must strictly adhere to the following workflow, architectural, and interaction constraints throughout our session:
+
+### 1. Git & Workflow
+* **Branching Strategy:** You must create and switch to a new branch named `test-platform`. This branch must be created specifically from the base commit hash: `aca800c1d19cd842f872239dc16e8a1c5051bfa2`.
+* **Initial Audit:** Before writing any test configurations or test cases, you must analyze the current repository state to identify and list all existing components, hooks, and utilities that require test coverage.
+
+### 2. Testing Framework & Tooling
+* **Vite Integration:** Because this is a Vite project, you must implement **Vitest** (along with React Testing Library) rather than Jest. Do not introduce Jest, as it requires redundant configuration that conflicts with Vite's build pipeline.
+* **Coverage Mandate:** You must write tests for *all* identified existing functionality in the codebase. Do not leave placeholder comments like `// TODO: add tests for X`.
+* **File Naming & Structure:** Test files must strictly follow industry-standard naming conventions (e.g., `[filename].test.jsx` or `[filename].test.tsx`) and be placed adjacently to the files they are testing.
+
+### 3. Documentation & Logging
+* **Decision Log:** You must create and continuously update a markdown file located exactly at `model/log.md`.
+* **Log Contents:** For every significant action (e.g., configuring the test runner, mocking a provider, handling a complex component test), you must log:
+ 1. The action taken.
+ 2. The alternative choices considered.
+ 3. The specific technical reasoning for why you made your choice.
+
+### 4. Interaction Protocol
+* **Three Options & A Recommendation:** Every time you complete a task, present a result, or need user direction, you must explicitly present exactly **3 distinct options** for how to move forward.
+* Immediately following those 3 options, you must provide **1 specific recommendation** from among those choices, explaining why it is the best path forward.
diff --git a/model/log.md b/model/log.md
new file mode 100644
index 0000000..0e23158
--- /dev/null
+++ b/model/log.md
@@ -0,0 +1,431 @@
+# Test Platform Decision Log
+
+This log captures every significant decision made while standing up the
+`test-platform` branch's Vitest-based test rig and its accompanying tests.
+Each entry records the action taken, the alternatives considered, and the
+reasoning behind the chosen path.
+
+---
+
+## 2026-04-22 — Session kickoff
+
+### 1. Branch creation strategy
+
+- **Action taken:** Created the `test-platform` branch from the mandated base
+ commit `aca800c1d19cd842f872239dc16e8a1c5051bfa2` via
+ `git checkout -b test-platform aca800c...`. The `CLAUDE.md` instruction file
+ was carried forward as a staged addition so the session-level instructions
+ persist on the new branch.
+- **Alternatives considered:**
+ 1. Reset `main` in place to the base commit. Rejected because it rewrites the
+ published `main` history and destroys the three security-patch commits
+ that already shipped.
+ 2. Branch from the current `main` tip. Rejected because the CLAUDE.md brief
+ explicitly pins the base commit — branching later would silently pull in
+ post-base changes.
+ 3. Stash `CLAUDE.md`, checkout, unstash. Rejected because since `CLAUDE.md`
+ does not exist in either end of the checkout, the ordinary checkout
+ already carries the staged add forward cleanly — stash/unstash adds noise
+ with no benefit.
+- **Reasoning:** The direct `checkout -b ` matches the branding contract
+ of "branch from this exact commit" while preserving the in-flight
+ instructions file. It is reversible (main untouched) and auditable.
+
+### 2. Initial repository audit
+
+- **Action taken:** Enumerated every source file under `src/**` and inspected
+ each one to classify its test surface. See "Audit inventory" below.
+- **Alternatives considered:**
+ 1. Infer coverage from the existing `tests/**` directory only. Rejected
+ because CLAUDE.md demands full coverage of "all functionality currently
+ present in the repository" — the existing three tests cover a sliver.
+ 2. Delegate the entire audit to an Explore subagent. Rejected because the
+ test-writing phase needs the concrete file contents in hand; reading
+ directly keeps context consolidated.
+- **Reasoning:** Writing good tests requires direct knowledge of each module's
+ public surface and side effects; batch-reading produced that picture in one
+ pass.
+
+#### Audit inventory
+
+Grouped by test strategy. Every listed symbol needs coverage on this branch.
+
+**A. Pure utility modules (node-safe unit tests):**
+
+| Module | Public surface |
+| -------------------------------------------------------- | -------------------------------------------------------- |
+| `src/components/helpers/ApiHelper.ts` | `API_BASE_URL` (env-derived with fallback) |
+| `src/components/helpers/RouteHelper.ts` | `BASE_ROUTE` (env-derived with fallback) |
+| `src/components/helpers/CapitalHelper.ts` | `isCapital(systemName, capitals)` |
+| `src/components/helpers/FactionHelper.ts` | `findFaction(factionKey, factions)` |
+| `src/components/helpers/NewTabHelper.ts` | `openInNewTab(url)` (wraps `window.open`) |
+| `src/components/helpers/index.ts` | Barrel — exports the three above |
+| `src/components/helpers/devStateInjector.ts` | `applyDevStateInjection(systems)` (env + localStorage) |
+| `src/components/GalaxyMap/gm.interactions.ts` | `getDistance(touch1, touch2)` *(covered by existing test)* |
+| `src/components/GalaxyMap/gm.selectors.ts` | `buildFactionFilterOptions(systems, factions)` *(covered)* |
+| `src/components/GalaxyMap/gm.types.ts` | Type-only module *(covered via `expectTypeOf`)* |
+| `src/components/hooks/types/Settings.ts` | `initialSettings` runtime default |
+| `src/components/hooks/types/index.ts` | Barrel (type re-exports + `initialSettings`) |
+
+**B. React hooks (need `renderHook`, jsdom):**
+
+| Hook | Surface |
+| -------------------------------------------------------- | --------------------------------------------------------- |
+| `src/components/hooks/useSettings.ts` | `{ settings, setFlashActive }` |
+| `src/components/hooks/useTooltip.ts` | `{ tooltip, showTooltip, hideTooltip }` |
+| `src/components/hooks/useWarmapAPI.ts` | `rawSystems/factions/capitals` + two fetchers |
+| `src/components/hooks/useFiltering.ts` | Composes `useWarmapAPI` + `useSettings` |
+| `src/components/hooks/useGalaxyViewport.ts` | Camera handlers (needs Konva stub) |
+| `src/components/hooks/usePinchZoom.ts` | Touch handlers (needs Konva stub + RAF) |
+
+**C. React components (need jsdom + RTL; Konva/react-router mocks):**
+
+| Component | Nature |
+| -------------------------------------------------------- | --------------------------------------------------------- |
+| `src/App.tsx` | Wires the router |
+| `src/main.tsx` | Bootstrap (smoke only) |
+| `src/components/core/PageTemplate.tsx` | Layout shell |
+| `src/components/core/SideMenu.tsx` | Nav links |
+| `src/components/pages/Error.tsx` | Route-error boundary |
+| `src/components/pages/Home.tsx` | Static content + `HomeCard` |
+| `src/components/pages/ToS.tsx` | Static content + `BulletPoint` |
+| `src/components/pages/GalaxyMap.tsx` | Konva-heavy map + tooltip |
+| `src/components/ui/BottomFilterPanel.tsx` | Search + faction multi-select |
+| `src/components/ui/StarSystem.tsx` | Konva primitive renderer |
+| `src/components/pages/index.ts` | Barrel re-exports |
+
+### 3. Test scope strategy
+
+- **Action taken:** Chose "Option B — Deep coverage on testable surface, smoke
+ on Konva." Every audited file gets at least one meaningful test; Konva-heavy
+ render paths (`GalaxyMap.tsx` render tree, `StarSystem.tsx`,
+ `useGalaxyViewport`, `usePinchZoom`) receive smoke-level coverage behind
+ mocks rather than full canvas assertions.
+- **Alternatives considered:**
+ 1. *Option A — Uniform smoke tests across everything.* Rejected as
+ information-light: Konva smoke tests mainly validate that mocks were
+ wired, not code behavior, so they do not repay the effort.
+ 2. *Option C — Skip Konva components entirely.* Rejected because CLAUDE.md
+ mandates coverage of *all* functionality currently present.
+- **Reasoning:** Option B is the highest-confidence-per-effort strategy. Tests
+ concentrate where assertions are load-bearing (pure logic, state hooks,
+ interactive DOM components) while still honoring the "all files tested"
+ contract. Canvas math is inherently hostile to jsdom; meaningful coverage
+ there would require a second-pass investment outside this session's budget.
+
+### 4. Testing framework extension — RTL + jsdom
+
+- **Action taken:** Added dev dependencies `@testing-library/react@16`,
+ `@testing-library/dom@10`, `@testing-library/jest-dom@6`,
+ `@testing-library/user-event@14`, and `jsdom@29` via `yarn add -D`. Rewrote
+ `vitest.config.ts` to load `@vitejs/plugin-react-swc`, pin the environment
+ to `jsdom`, register a `src/test/setup.ts` setup file, and widen `include`
+ to pick up both legacy `tests/**/*.test.{ts,tsx}` and the new adjacent
+ `src/**/*.test.{ts,tsx}` pattern. Created `src/test/setup.ts` to import
+ `@testing-library/jest-dom/vitest`, run `cleanup()` after each test, and
+ polyfill `matchMedia` + `ResizeObserver` for libraries that touch them.
+ Updated `tsconfig.vitest.json` to extend `tsconfig.app.json` (so DOM libs
+ are visible), pull in `vitest/globals` + `@testing-library/jest-dom` types,
+ and include the new `src/**/*.test.{ts,tsx}` and `src/test/**/*` paths.
+ Added `test`, `test:watch`, and `test:coverage` npm scripts.
+- **Alternatives considered:**
+ 1. *Single `environment: 'node'` kept as-is; per-file `/// @vitest-environment jsdom`.*
+ Rejected because it forces every component test file to carry a pragma
+ and makes it easy to forget, leading to mysterious `window is undefined`
+ failures.
+ 2. *Vitest `projects` / `environmentMatchGlobs` split.* Rejected as premature
+ complexity — the `node` tests work fine under jsdom and the session's
+ scope does not need two parallel runners.
+ 3. *Drop the stand-alone `tsconfig.vitest.json` and fold test includes into
+ `tsconfig.app.json`.* Rejected because `tsconfig.app.json` deliberately
+ excludes test files from production type-checking; keeping them
+ separated preserves that.
+ 4. *Install `@testing-library/react@15` (peer on legacy API)* instead of
+ `v16`. Rejected because `v16` ships React 18/19 support out of the box;
+ we already run React 18.3.
+- **Reasoning:** A single global jsdom environment is the lowest-friction
+ configuration that still lets node-style tests pass, and matches the
+ out-of-the-box conventions most React teams recognize as "industry
+ standard." Keeping a dedicated `tsconfig.vitest.json` avoids polluting the
+ app build surface with vitest globals.
+
+- **Verification:** `yarn test` runs the three pre-existing test files
+ (`gm.types`, `gm.selectors`, `gm.interactions`) under the new configuration
+ with all 11 assertions passing. This establishes a green baseline before
+ adding new suites.
+
+### 5. localStorage polyfill in the test setup
+
+- **Action taken:** Added a Map-backed `Storage` polyfill to
+ `src/test/setup.ts`, installed on `window.localStorage` and
+ `window.sessionStorage` whenever the existing object lacks `setItem`. A
+ `beforeEach` hook clears both stores so tests are isolated.
+- **Alternatives considered:**
+ 1. *Rely on jsdom's native storage.* Rejected: Vitest 4 / Node 25 stubs
+ `window.localStorage` to a property-less empty object (a probe showed
+ `ls.clear`, `ls.setItem`, `ls.removeItem` all `undefined`), with a noisy
+ `--localstorage-file was provided without a valid path` warning.
+ 2. *Pass `--localstorage-file` or configure Node's experimental localStorage
+ backing file.* Rejected as heavy and environment-specific; ties the rig
+ to Node 25's experimental flag.
+ 3. *Use `vi.stubGlobal('localStorage', fake)` per test.* Rejected because
+ several modules under test (devStateInjector, useWarmapAPI consumers)
+ read `window.localStorage` at runtime, so a setup-file polyfill is less
+ error-prone than remembering to stub per-file.
+- **Reasoning:** A deterministic, Map-backed polyfill restores Web Storage
+ semantics for all tests with one line of setup — matching the "industry
+ standard" experience devs expect from jsdom.
+
+### 6. Relocating the pre-existing tests to sit adjacent to source
+
+- **Action taken:** Moved `tests/gm.interactions.test.ts`,
+ `tests/gm.selectors.test.ts`, and `tests/gm.types.test.ts` to live next to
+ their source files under `src/components/GalaxyMap/` (using relative imports
+ like `./gm.interactions`). The now-empty `tests/` directory was removed and
+ `tests/**/*` was dropped from both `vitest.config.ts`'s `include` and
+ `tsconfig.vitest.json`'s `include`.
+- **Alternatives considered:**
+ 1. *Keep the legacy `tests/` directory alongside new adjacent tests.*
+ Rejected because CLAUDE.md mandates adjacent placement for all test
+ files and a mixed strategy invites drift.
+ 2. *Leave old tests in place and only add new adjacent tests.* Rejected for
+ the same reason.
+- **Reasoning:** Consistency makes the project discoverable (each source file
+ has a predictable test neighbor). Each relocation preserved all original
+ assertions and gained a few extra cases (case-sensitive sort, empty list,
+ negative coordinates, new type aliases) while reshaping the imports to the
+ adjacent relative form.
+
+### 7. Shared Konva mock module
+
+- **Action taken:** Introduced `src/test/konvaMocks.tsx` exporting
+ `reactKonvaStubs` (a set of `forwardRef` components for `Stage`, `Layer`,
+ `Image`, `Text`, `Group`, `Rect`, `Line`, `Circle`) and `konvaStub` (a
+ `default` namespace with stub `Animation` and `Text` classes). Each mocked
+ react-konva primitive uses `useImperativeHandle` to return a fake node
+ exposing the methods callers touch in `useEffect` (`opacity`, `scale`,
+ `getLayer`, `getStage`, `container`, `batchDraw`, `getPosition`,
+ `getPointerPosition`, `getRelativePointerPosition`, `x`, `y`, `scaleX`,
+ `destroy`). Scalar props are forwarded to the DOM as `data-*` attributes
+ so test assertions can still locate them.
+- **Alternatives considered:**
+ 1. *Inline the mock inside each test file.* Rejected: we would end up with
+ three near-identical copies (`App`, `StarSystem`, `GalaxyMap`, `main`,
+ pages barrel), so any future fix would need to be applied in parallel.
+ 2. *Mock `konva` and `react-konva` globally in `src/test/setup.ts`.*
+ Rejected because `vi.mock` is hoisted per-file at parse time — putting
+ it in setup doesn't hoist into other test modules — and globally mocking
+ Konva would block anyone who wants to write a canvas-enabled integration
+ test later.
+ 3. *Install `jest-canvas-mock` / `canvas` polyfill so real Konva can run
+ under jsdom.* Rejected because Option B explicitly aimed for smoke
+ coverage; pulling in a canvas shim is a second-pass investment.
+- **Reasoning:** A shared mock minimizes duplication while staying per-file
+ (`vi.mock` still hoists from each consumer). Exposing methods via
+ `useImperativeHandle` means StarSystem's useEffect animations can call
+ `pulseNode.scale(...)` without a `getLayer is not a function` TypeError —
+ critical for the "smoke test mounts cleanly" guarantee.
+
+### 8. Authoring strategy per audit tier
+
+- **Pure utilities:** authored one suite per source file with positive-path,
+ edge-case (empty input, missing key, falsy values, case sensitivity), and
+ contract tests (shape conformance, type-level assertions).
+- **Hooks:** used RTL's `renderHook` + `act` for state hooks; mocked `fetch`
+ via `vi.stubGlobal` for the API hooks; faked rAF and `performance.now`
+ with a queue + controllable clock for viewport/pinch hooks to assert both
+ throttle and frame-coalescing behavior deterministically.
+- **Components:**
+ - `PageTemplate`, `SideMenu`, `Home`, `ToS`, `Error` use `MemoryRouter`
+ / `createMemoryRouter` to exercise all three `ErrorPage` branches (route
+ error response, thrown `Error`, non-Error).
+ - `BottomFilterPanel` drives the toggle chevron, search input, and help
+ tooltip via `fireEvent`/`userEvent`, and simulates a mobile viewport by
+ rewriting `window.innerWidth` so the click-to-toggle tooltip path is hit.
+ - `StarSystem`, `GalaxyMap`, `App`, and `main` use the shared Konva mock
+ and stub fetch to exercise the component/page/bootstrap surface without
+ requiring a real canvas. `main.test.tsx` wraps `createRoot` to assert
+ that bootstrap actually attaches to the supplied `#react-root` element.
+
+### 9. Final rig verification
+
+- **Action taken:** Ran `yarn test` (`vitest run`) — the full suite reports
+ **29 test files / 122 tests passing**. Also ran `npx tsc -p
+ tsconfig.vitest.json --noEmit` (clean) and `yarn build` (production build
+ succeeds with the same output as before the test work).
+- **Alternatives considered:**
+ 1. *Only run `yarn test`.* Rejected — a type-clean but untested config
+ slippage is exactly the sort of silent regression the rig is supposed
+ to prevent.
+ 2. *Also run `yarn lint`.* Noted as reasonable for a follow-up but skipped
+ here: ESLint config is outside the test-rig scope and the existing
+ codebase warns about some pre-existing issues unrelated to this work.
+- **Reasoning:** `test` + `tsc --noEmit` + `build` covers the three
+ orthogonal failure modes (runtime assertions, type safety, production
+ bundling). All three are green, which satisfies the CLAUDE.md "must load
+ correctly and provide standard, readable output" bar.
+
+#### Final coverage map
+
+| File | Adjacent test | Depth |
+| ----------------------------------------------------- | --------------------------------------------------- | ----- |
+| `src/App.tsx` | `src/App.test.tsx` | smoke |
+| `src/main.tsx` | `src/main.test.tsx` | smoke |
+| `src/components/core/PageTemplate.tsx` | `…/PageTemplate.test.tsx` | deep |
+| `src/components/core/SideMenu.tsx` | `…/SideMenu.test.tsx` | deep |
+| `src/components/GalaxyMap/gm.interactions.ts` | `…/gm.interactions.test.ts` | deep |
+| `src/components/GalaxyMap/gm.selectors.ts` | `…/gm.selectors.test.ts` | deep |
+| `src/components/GalaxyMap/gm.types.ts` | `…/gm.types.test.ts` | deep |
+| `src/components/helpers/ApiHelper.ts` | `…/ApiHelper.test.ts` | deep |
+| `src/components/helpers/CapitalHelper.ts` | `…/CapitalHelper.test.ts` | deep |
+| `src/components/helpers/devStateInjector.ts` | `…/devStateInjector.test.ts` | deep |
+| `src/components/helpers/FactionHelper.ts` | `…/FactionHelper.test.ts` | deep |
+| `src/components/helpers/index.ts` | `…/index.test.ts` | deep |
+| `src/components/helpers/NewTabHelper.ts` | `…/NewTabHelper.test.ts` | deep |
+| `src/components/helpers/RouteHelper.ts` | `…/RouteHelper.test.ts` | deep |
+| `src/components/hooks/types/Settings.ts` | `…/types/Settings.test.ts` | deep |
+| `src/components/hooks/types/index.ts` | `…/types/index.test.ts` (covers all 7 type files) | deep |
+| `src/components/hooks/useFiltering.ts` | `…/useFiltering.test.ts` | deep |
+| `src/components/hooks/useGalaxyViewport.ts` | `…/useGalaxyViewport.test.ts` | deep |
+| `src/components/hooks/usePinchZoom.ts` | `…/usePinchZoom.test.ts` | deep |
+| `src/components/hooks/useSettings.ts` | `…/useSettings.test.ts` | deep |
+| `src/components/hooks/useTooltip.ts` | `…/useTooltip.test.ts` | deep |
+| `src/components/hooks/useWarmapAPI.ts` | `…/useWarmapAPI.test.ts` | deep |
+| `src/components/pages/Error.tsx` | `…/Error.test.tsx` | deep |
+| `src/components/pages/GalaxyMap.tsx` | `…/GalaxyMap.test.tsx` | smoke |
+| `src/components/pages/Home.tsx` | `…/Home.test.tsx` | deep |
+| `src/components/pages/index.ts` | `…/index.test.ts` | deep |
+| `src/components/pages/ToS.tsx` | `…/ToS.test.tsx` | deep |
+| `src/components/ui/BottomFilterPanel.tsx` | `…/BottomFilterPanel.test.tsx` | deep |
+| `src/components/ui/StarSystem.tsx` | `…/StarSystem.test.tsx` | smoke |
+
+"Deep" means meaningful behavior / state / DOM assertions; "smoke" means the
+component mounts cleanly under mocks with a small number of structural
+assertions — the documented Option B posture.
+
+### 10. Coverage reporter (`@vitest/coverage-v8`)
+
+- **Action taken:** Installed `@vitest/coverage-v8@4.0.8` (pinned to match
+ `vitest@4.0.8` exactly), configured the reporter inside `vitest.config.ts`
+ with `provider: 'v8'`, text + html + lcov outputs, `reportsDirectory:
+ './coverage'`, `include: ['src/**/*.{ts,tsx}']`, and excludes for test
+ files, the `src/test/**` rig directory, and `src/vite-env.d.ts`. Added
+ `coverage/` to `.gitignore` so generated reports are not committed. The
+ `test:coverage` npm script (already present) now produces real numbers.
+- **Alternatives considered:**
+ 1. *Use `@vitest/coverage-istanbul` instead.* Rejected: v8 is faster, has
+ zero transform cost in watch mode, and is the default Vitest recommends.
+ Istanbul only wins when you need legacy-browser coverage semantics that
+ are irrelevant in this node/jsdom context.
+ 2. *Leave `coverage/` uncomitted but not in `.gitignore`.* Rejected — every
+ local `yarn test:coverage` run would dirty the working tree and show
+ dozens of generated HTML/CSS assets as untracked. `.gitignore` is the
+ conventional answer.
+ 3. *Accept the latest `@vitest/coverage-v8@4.1.5`.* Rejected: v4.1.x
+ imports `BaseCoverageProvider` from `vitest/node`, which v4.0.8 does
+ not export. A version mismatch surfaces as an unhandled import error on
+ every coverage run. Pinning both packages to the exact same minor
+ resolves it.
+- **Reasoning:** The `test:coverage` script existed as an alias for
+ `vitest run --coverage` but without the reporter installed, it failed with
+ a cryptic missing-provider error. Wiring v8 gives reviewers real numbers
+ without costing dev-mode performance.
+
+- **Initial report (after this change):** All files `73.9% stmts / 61.21%
+ branches / 70.52% funcs / 76.75% lines`. Deep-tested modules (helpers,
+ most hooks, Error / Home / ToS / PageTemplate / SideMenu pages) land at
+ 100%. The Option-B smoke targets (`GalaxyMap.tsx`, `StarSystem.tsx`,
+ `usePinchZoom.ts`) are the sources of the un-green lines; that matches the
+ documented posture. Type-only files (`ControlInfo.ts`, `StarSystemType.ts`,
+ etc.) and re-export barrels (`helpers/index.ts`, `pages/index.ts`,
+ `hooks/types/index.ts`) report as 0/0 because v8 cannot see them as having
+ executable code — this is a cosmetic display quirk, not a real gap.
+
+### 11. GitHub Actions CI workflow
+
+- **Action taken:** Added `.github/workflows/test.yml`. The workflow triggers
+ on `push` to `main` or `test-platform` and on any `pull_request` targeting
+ `main`. It checks out the repo, installs Node (matrix of 20 + 22) with
+ yarn caching via `actions/setup-node@v4`, runs `yarn install
+ --frozen-lockfile`, typechecks the test sources with `npx tsc -p
+ tsconfig.vitest.json --noEmit`, runs `yarn test:coverage`, and uploads
+ the `coverage/` directory as a 14-day retained artifact from the Node 20
+ leg only.
+- **Alternatives considered:**
+ 1. *Single-version matrix (Node 20 only).* Rejected: running both 20 and
+ 22 surfaces version-drift issues early at negligible cost given yarn
+ caching.
+ 2. *Use npm instead of yarn in CI.* Rejected — project uses yarn 1 and
+ ships a `yarn.lock` pinned by the `packageManager` field; swapping
+ lockfile tools creates drift risk.
+ 3. *Post coverage to Codecov via `codecov/codecov-action`.* Deferred —
+ requires a repo-scoped secret (`CODECOV_TOKEN`) the maintainers would
+ have to provision. Artifact upload gives reviewers the same HTML
+ report without external dependencies; Codecov can be added later.
+ 4. *Skip the `tsc --noEmit` step.* Rejected: a vitest green run does not
+ catch type regressions in test files themselves, and TypeScript
+ misalignments in tests have bitten this project's type assertions
+ before (the `expectTypeOf` suites).
+- **Reasoning:** Three orthogonal failure modes — install reproducibility,
+ type safety of tests, and runtime assertions — each get a step. The
+ matrix covers the two supported LTS lines. The coverage artifact makes
+ reviewer inspection a click rather than a local run.
+
+- **Note:** GitHub Actions run against the PR's *base repository* config,
+ so the workflow only becomes active for PR checks on
+ `BattletechModders/RogueTechWarMap` once this PR merges into `main`.
+ Until then, pushes to `nx-thaddeusaid/RogueTechWarMap:test-platform` will
+ still trigger runs in the fork's own Actions (if enabled), which serves
+ as a pre-merge smoke.
+
+### 12. Extracting inline map helpers for deep coverage
+
+- **Action taken:** Moved the inline helpers from `GalaxyMap.tsx` and
+ `StarSystem.tsx` into two adjacent modules that are importable by tests:
+ - `src/components/pages/GalaxyMap.helpers.ts` — `getViewportSize`,
+ `getTooltipFontSize`, `getDesktopLineSegments(line, index, {
+ titleFontSize, bodyFontSize })`, and `parseMobileTooltipData(text)`.
+ - `src/components/ui/StarSystem.helpers.ts` — `buildControlItems`,
+ `formatControlLine`, `formatSystemState`, `formatDamageLevel`, and
+ `buildTooltipText({ system, factions, includeTapHint })`.
+ Rewired `GalaxyMap.tsx` and `StarSystem.tsx` to call the extracted
+ helpers (the component now wraps `getDesktopLineSegments` in a small
+ `segmentsFor` closure so it keeps its previous zero-argument call shape
+ at the two Konva call sites, and `buildTooltipText` is invoked with the
+ explicit `{ system, factions }` object). Added 31 deep adjacent tests
+ across `StarSystem.helpers.test.ts` (18) and `GalaxyMap.helpers.test.ts`
+ (13) covering positive paths, edge cases (empty inputs, undefined state,
+ whitespace-only damage level), ordering guarantees, the Owner/Damage
+ label-split branch, and the mobile-tooltip parser's control-block
+ skipping semantics.
+- **Alternatives considered:**
+ 1. *Leave the helpers inline and write DOM-level assertions against the
+ rendered tooltip text.* Rejected — asserting multi-line `\n`-joined
+ tooltip strings via the Konva mock is indirect and brittle; a unit
+ test against the pure function is clearer and cheaper to maintain.
+ 2. *Extract into a single shared `tooltip.helpers.ts` used by both the
+ map page and the star-system node.* Rejected — the two call sites
+ have different responsibilities (desktop tooltip-layout parsing vs.
+ tooltip text composition) and a shared module would leak concerns;
+ keeping them adjacent to the component that owns them matches the
+ rest of the repository's file layout.
+ 3. *Re-export the helpers from the components themselves for tests.*
+ Rejected because that couples production exports to test needs and
+ complicates tree-shaking.
+- **Reasoning:** Pure helpers live outside the Konva render tree, so they
+ are testable under jsdom without any canvas or mock scaffolding. Moving
+ them into their own modules took GalaxyMap.tsx from 72% / 40% / 58% /
+ 74% to 79% / 48% / 59% / 82% and upgraded `GalaxyMap.helpers.ts` to
+ 94.87% stmts / 88.46% branches / 100% funcs / 94.87% lines, while
+ `StarSystem.helpers.ts` lands at 100% across the board. Overall project
+ coverage moved from 73.9% / 61.21% / 70.52% / 76.75% to **79.22% /
+ 69.5% / 76.16% / 82.43%** (stmts / branches / funcs / lines), and the
+ test count grew from 122 to 153 across 31 files, all green.
+
+ The remaining untested lines concentrate where Option B expected them:
+ inside the Konva rendering bodies of `GalaxyMap.tsx` and
+ `StarSystem.tsx`, the pinch-gesture math in `usePinchZoom.ts`, and the
+ inline height-animation setup in `BottomFilterPanel.tsx`. Lifting those
+ would require a real canvas polyfill or component-level interaction
+ tests, which remain out of scope for this rig.
+
diff --git a/package.json b/package.json
index 42fd1f2..7d696c0 100644
--- a/package.json
+++ b/package.json
@@ -7,11 +7,14 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage"
},
"dependencies": {
"@material-tailwind/react": "^2.1.10",
- "axios": "^1.13.2",
+ "axios": "^1.13.5",
"konva": "^9.3.18",
"localforage": "^1.10.0",
"lucide-react": "^0.515.0",
@@ -22,30 +25,42 @@
"react-dom": "^18.3.1",
"react-icons": "^5.3.0",
"react-konva": "18",
- "react-router-dom": "^6.26.2",
+ "react-router-dom": "^6.30.3",
"react-select": "^5.10.1",
"swr": "^2.2.5"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.0",
"@types/react": "18.2.42",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
+ "@vitest/coverage-v8": "4.0.8",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
+ "jsdom": "^29.0.2",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.12",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.1",
- "vite": "^5.4.1",
+ "vite": "^8.0.1",
"vitest": "^4.0.8"
},
"resolutions": {
- "esbuild": "0.27.0"
+ "minimatch": "^3.1.3",
+ "rollup": "^4.59.0",
+ "ajv": "6.14.0",
+ "flatted": "3.4.2",
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3",
+ "glob": "^10.5.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
diff --git a/public/galaxyBackground2.svg b/public/galaxyBackground2.svg
index 0d405a4..74488c7 100644
--- a/public/galaxyBackground2.svg
+++ b/public/galaxyBackground2.svg
@@ -1,2460 +1,253 @@
-
diff --git a/public/galaxyBackground3.svg b/public/galaxyBackground3.svg
deleted file mode 100644
index 74488c7..0000000
--- a/public/galaxyBackground3.svg
+++ /dev/null
@@ -1,253 +0,0 @@
-
-
-
-
diff --git a/src/App.test.tsx b/src/App.test.tsx
new file mode 100644
index 0000000..34735e4
--- /dev/null
+++ b/src/App.test.tsx
@@ -0,0 +1,36 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render } from '@testing-library/react';
+
+vi.mock('react-konva', async () => {
+ const mod = await import('./test/konvaMocks');
+ return mod.reactKonvaStubs;
+});
+
+vi.mock('konva', async () => {
+ const mod = await import('./test/konvaMocks');
+ return mod.konvaStub;
+});
+
+const buildResponse = (payload: unknown) =>
+ ({ ok: true, json: async () => payload } as unknown as Response);
+
+describe('App', () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockImplementation(async (url: string) => {
+ if (url.includes('/starmap/warmap')) return buildResponse([]);
+ return buildResponse({});
+ })
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('mounts under RouterProvider without throwing', async () => {
+ const { default: App } = await import('./App');
+ expect(() => render()).not.toThrow();
+ });
+});
diff --git a/src/App.tsx b/src/App.tsx
index 99c8a47..b35b520 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,9 +5,11 @@ import {
RouterProvider,
} from 'react-router-dom';
import { Map } from './components/pages/';
+// Keep this import commented until the legacy home route is restored.
// import { Home, Map } from './components/pages/';
import ErrorPage from './components/pages/Error';
import { BASE_ROUTE } from './components/helpers/RouteHelper.ts';
+// Kept for later route re-introduction when the Terms-of-Service view is restored.
// import { ToS } from './components/pages/ToS';
const router = createBrowserRouter(
@@ -16,6 +18,7 @@ const router = createBrowserRouter(
} errorElement={} />
} />
+ {/* Parking legacy routes while main route handling is stabilized. */}
{/* } /> */}
{/* } /> */}
>
@@ -26,7 +29,8 @@ export default function App() {
return (
}
+ // Uncomment this with when adding a shared app-wide fallback screen.
+ // fallbackElement={}
/>
);
}
diff --git a/src/assets/crosshairs.svg b/src/assets/crosshairs.svg
new file mode 100644
index 0000000..f9ad04a
--- /dev/null
+++ b/src/assets/crosshairs.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/src/assets/joli-rouge-icon.svg b/src/assets/joli-rouge-icon.svg
new file mode 100644
index 0000000..05a97c9
--- /dev/null
+++ b/src/assets/joli-rouge-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/shield.svg b/src/assets/shield.svg
new file mode 100644
index 0000000..a618d7f
--- /dev/null
+++ b/src/assets/shield.svg
@@ -0,0 +1,23 @@
+
\ No newline at end of file
diff --git a/src/components/GalaxyMap/gm.interactions.test.ts b/src/components/GalaxyMap/gm.interactions.test.ts
new file mode 100644
index 0000000..79855b1
--- /dev/null
+++ b/src/components/GalaxyMap/gm.interactions.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect } from 'vitest';
+import { getDistance } from './gm.interactions';
+
+describe('getDistance', () => {
+ it('returns 0 for identical points', () => {
+ const t1 = { clientX: 100, clientY: 200 } as unknown as Touch;
+ const t2 = { clientX: 100, clientY: 200 } as unknown as Touch;
+
+ expect(getDistance(t1, t2)).toBe(0);
+ });
+
+ it('computes Euclidean distance between two touch points (3-4-5 triangle)', () => {
+ const t1 = { clientX: 0, clientY: 0 } as unknown as Touch;
+ const t2 = { clientX: 3, clientY: 4 } as unknown as Touch;
+
+ expect(getDistance(t1, t2)).toBe(5);
+ });
+
+ it('is symmetric (distance A→B equals distance B→A)', () => {
+ const t1 = { clientX: 10, clientY: 20 } as unknown as Touch;
+ const t2 = { clientX: -5, clientY: 7 } as unknown as Touch;
+
+ expect(getDistance(t1, t2)).toBeCloseTo(getDistance(t2, t1), 10);
+ });
+
+ it('handles negative coordinates', () => {
+ const t1 = { clientX: -10, clientY: -10 } as unknown as Touch;
+ const t2 = { clientX: -7, clientY: -6 } as unknown as Touch;
+
+ expect(getDistance(t1, t2)).toBe(5);
+ });
+});
diff --git a/src/components/GalaxyMap/gm.interactions.ts b/src/components/GalaxyMap/gm.interactions.ts
new file mode 100644
index 0000000..426aeb2
--- /dev/null
+++ b/src/components/GalaxyMap/gm.interactions.ts
@@ -0,0 +1,5 @@
+export function getDistance(touch1: Touch, touch2: Touch): number {
+ const dx = touch1.clientX - touch2.clientX;
+ const dy = touch1.clientY - touch2.clientY;
+ return Math.sqrt(dx * dx + dy * dy);
+}
diff --git a/src/components/GalaxyMap/gm.selectors.test.ts b/src/components/GalaxyMap/gm.selectors.test.ts
new file mode 100644
index 0000000..2b44179
--- /dev/null
+++ b/src/components/GalaxyMap/gm.selectors.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect } from 'vitest';
+import { buildFactionFilterOptions } from './gm.selectors';
+
+describe('buildFactionFilterOptions', () => {
+ it('returns unique, sorted faction names using prettyName when available', () => {
+ const systems = [
+ { owner: 'FACTION_A' },
+ { owner: 'FACTION_B' },
+ { owner: 'FACTION_A' }, // duplicate
+ { owner: 'FACTION_C' },
+ ] as any[];
+
+ const factions = {
+ FACTION_A: { prettyName: 'Alpha' },
+ FACTION_B: { prettyName: 'Bravo' },
+ FACTION_C: { prettyName: 'Charlie' },
+ } as any;
+
+ const result = buildFactionFilterOptions(systems, factions);
+
+ expect(result).toEqual(['Alpha', 'Bravo', 'Charlie']);
+ });
+
+ it('falls back to owner key when prettyName is missing or factions entry is absent', () => {
+ const systems = [{ owner: 'FACTION_X' }, { owner: 'FACTION_Y' }] as any[];
+
+ const factions = {
+ FACTION_X: { prettyName: undefined },
+ } as any;
+
+ const result = buildFactionFilterOptions(systems, factions);
+
+ expect(result.sort()).toEqual(['FACTION_X', 'FACTION_Y'].sort());
+ });
+
+ it('ignores falsy names (null/empty string)', () => {
+ const systems = [
+ { owner: 'FACTION_NULL' },
+ { owner: 'FACTION_EMPTY' },
+ { owner: 'FACTION_OK' },
+ ] as any[];
+
+ const factions = {
+ FACTION_NULL: { prettyName: null },
+ FACTION_EMPTY: { prettyName: '' },
+ FACTION_OK: { prettyName: 'Valid' },
+ } as any;
+
+ const result = buildFactionFilterOptions(systems, factions);
+
+ expect(result).toEqual(['FACTION_NULL', 'Valid']);
+ });
+
+ it('returns an empty array when systems list is empty', () => {
+ const result = buildFactionFilterOptions([] as any[], {} as any);
+ expect(result).toEqual([]);
+ });
+
+ it('sorts case-sensitively via localeCompare', () => {
+ const systems = [
+ { owner: 'a' },
+ { owner: 'b' },
+ { owner: 'c' },
+ ] as any[];
+ const factions = {
+ a: { prettyName: 'zebra' },
+ b: { prettyName: 'Apple' },
+ c: { prettyName: 'banana' },
+ } as any;
+
+ const result = buildFactionFilterOptions(systems, factions);
+ expect(result).toEqual(['Apple', 'banana', 'zebra']);
+ });
+});
diff --git a/src/components/GalaxyMap/gm.selectors.ts b/src/components/GalaxyMap/gm.selectors.ts
new file mode 100644
index 0000000..8ce0f65
--- /dev/null
+++ b/src/components/GalaxyMap/gm.selectors.ts
@@ -0,0 +1,16 @@
+import type { DisplayStarSystemType, FactionDataType } from '../hooks/types';
+
+export function buildFactionFilterOptions(
+ systems: DisplayStarSystemType[],
+ factions: FactionDataType
+): string[] {
+ const names = new Set();
+
+ for (const system of systems) {
+ const owner = system.owner;
+ const pretty = factions[owner]?.prettyName ?? owner;
+ if (pretty) names.add(pretty);
+ }
+
+ return Array.from(names).sort((a, b) => a.localeCompare(b));
+}
diff --git a/tests/gm.types.test.ts b/src/components/GalaxyMap/gm.types.test.ts
similarity index 67%
rename from tests/gm.types.test.ts
rename to src/components/GalaxyMap/gm.types.test.ts
index d24762c..eb43a2a 100644
--- a/tests/gm.types.test.ts
+++ b/src/components/GalaxyMap/gm.types.test.ts
@@ -3,15 +3,18 @@ import type {
Point,
StageSize,
TooltipData,
+ TooltipControlItem,
ViewTransform,
GalaxyMapRenderProps,
-} from '../src/components/GalaxyMap/gm.types';
+ FactionName,
+ FactionNameList,
+} from './gm.types';
import type {
DisplayStarSystemType,
FactionDataType,
Settings,
-} from '../src/components/hooks/types';
+} from '../hooks/types';
describe('gm.types', () => {
it('Point has x/y as numbers', () => {
@@ -26,7 +29,7 @@ describe('gm.types', () => {
expectTypeOf(s.height).toBeNumber();
});
- it('TooltipData has required fields and optional onTouch', () => {
+ it('TooltipData has required fields and optional onTouch / controlItems', () => {
const t: TooltipData = { visible: true, x: 10, y: 20, text: 'hello' };
expectTypeOf(t.visible).toBeBoolean();
expectTypeOf(t.x).toBeNumber();
@@ -34,6 +37,14 @@ describe('gm.types', () => {
expectTypeOf(t.text).toBeString();
expectTypeOf(t.onTouch).toEqualTypeOf<(() => void) | undefined>();
expectTypeOf>().toBeFunction();
+ expectTypeOf(t.controlItems).toEqualTypeOf();
+ });
+
+ it('TooltipControlItem has name/control/players fields', () => {
+ const c: TooltipControlItem = { name: 'x', control: 50, players: 3 };
+ expectTypeOf(c.name).toBeString();
+ expectTypeOf(c.control).toBeNumber();
+ expectTypeOf(c.players).toBeNumber();
});
it('ViewTransform includes scale and position as Point', () => {
@@ -43,22 +54,22 @@ describe('gm.types', () => {
});
it('GalaxyMapRenderProps matches expected shapes', () => {
- // systems is an array of DisplayStarSystemType
expectTypeOf().toEqualTypeOf<
DisplayStarSystemType[]
>();
-
- // individual element type also matches
expectTypeOf<
GalaxyMapRenderProps['systems'][number]
>().toEqualTypeOf();
-
- // factions matches FactionDataType
expectTypeOf<
GalaxyMapRenderProps['factions']
>().toEqualTypeOf();
-
- // settings matches Settings
expectTypeOf().toEqualTypeOf();
});
+
+ it('FactionName is a string alias and FactionNameList is FactionName[]', () => {
+ const name: FactionName = 'Davion';
+ const list: FactionNameList = ['Davion', 'Kurita'];
+ expectTypeOf(name).toBeString();
+ expectTypeOf(list).toEqualTypeOf();
+ });
});
diff --git a/src/components/GalaxyMap/gm.types.ts b/src/components/GalaxyMap/gm.types.ts
index fb6192a..43010db 100644
--- a/src/components/GalaxyMap/gm.types.ts
+++ b/src/components/GalaxyMap/gm.types.ts
@@ -6,26 +6,33 @@ import type {
export type Point = { x: number; y: number };
-/** Stage (canvas) size used by React-Konva */
+/** Stage viewport dimensions passed to and updated when the browser resizes. */
export interface StageSize {
width: number;
height: number;
}
-/** Tooltip shape as consumed by GalaxyMap.tsx */
+/** Tooltip payload shape read by GalaxyMap and rendered in both desktop and mobile views. */
export interface TooltipData {
visible: boolean;
x: number;
y: number;
text: string;
onTouch?: () => void;
+ controlItems?: TooltipControlItem[];
}
-/** View transform pieces GalaxyMap manages internally */
+export interface TooltipControlItem {
+ name: string;
+ control: number;
+ players: number;
+}
+
+/** Camera transform snapshot for the map viewport. */
export interface ViewTransform {
- /** Current zoom scale (e.g., 0.2 .. 25) */
+ /** Current zoom level, where 1 is normal scale and bounds are enforced by the viewport hook. */
scale: number;
- /** Stage position (screen-space) */
+ /** Stage translation in screen coordinates after pan/drag and zoom operations. */
position: Point;
}
diff --git a/src/components/core/PageTemplate.test.tsx b/src/components/core/PageTemplate.test.tsx
new file mode 100644
index 0000000..cead004
--- /dev/null
+++ b/src/components/core/PageTemplate.test.tsx
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import PageTemplate from './PageTemplate';
+
+const renderTemplate = (children: React.ReactNode) =>
+ render(
+
+ {children}
+
+ );
+
+describe('PageTemplate', () => {
+ it('renders its children inside the main content area', () => {
+ renderTemplate(
+ )}
+ {/* Render slide-up filters that control search and faction matching. */}
{
- const names = new Set();
- for (const system of systems) {
- const owner = system.owner;
- const pretty = factions[owner]?.prettyName ?? owner;
- if (pretty) names.add(pretty);
- }
- return Array.from(names).sort((a, b) => a.localeCompare(b));
- }, [systems, factions])}
+ factions={useMemo(
+ () => buildFactionFilterOptions(systems, factions),
+ [systems, factions]
+ )}
selectedFactions={selectedFactions}
setSelectedFactions={setSelectedFactions}
/>
diff --git a/src/components/pages/Home.test.tsx b/src/components/pages/Home.test.tsx
new file mode 100644
index 0000000..45e5c87
--- /dev/null
+++ b/src/components/pages/Home.test.tsx
@@ -0,0 +1,79 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { Home, HomeCard } from './Home';
+
+const renderHome = () =>
+ render(
+
+
+
+ );
+
+describe('Home page', () => {
+ it('renders the welcome headline', () => {
+ renderHome();
+ expect(
+ screen.getByRole('heading', { name: /Welcome to the War Commander/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders five HomeCards with their external links', () => {
+ renderHome();
+ expect(screen.getByRole('link', { name: /RogueWar Discord/i })).toHaveAttribute(
+ 'href',
+ 'https://discord.gg/JU8tuMG'
+ );
+ expect(screen.getByRole('link', { name: /Mods-In-Exile/i })).toHaveAttribute(
+ 'href',
+ 'https://discourse.modsinexile.com/t/rogue-tech/134'
+ );
+ expect(screen.getByRole('link', { name: /RT Discord/i })).toHaveAttribute(
+ 'href',
+ 'https://discord.gg/93kxWQZ'
+ );
+ expect(screen.getByRole('link', { name: /^Wiki$/i })).toHaveAttribute(
+ 'href',
+ 'https://roguetech.gamepedia.com'
+ );
+ expect(screen.getByRole('link', { name: /Donate/i })).toHaveAttribute(
+ 'href',
+ 'https://ko-fi.com/roguetech28443'
+ );
+ });
+
+ it('renders the "How to Participate" heading', () => {
+ renderHome();
+ expect(
+ screen.getByRole('heading', { name: /How to Participate/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the side menu via PageTemplate', () => {
+ renderHome();
+ expect(screen.getByText('Home')).toBeInTheDocument();
+ expect(screen.getByText('Map')).toBeInTheDocument();
+ });
+});
+
+describe('HomeCard', () => {
+ // CardStyle is not exported, so test the public HomeCard API by rendering.
+ it('renders the heading, children, and button label with the supplied URI', () => {
+ render(
+
+ card body
+
+ );
+ expect(screen.getByText('Test Heading')).toBeInTheDocument();
+ expect(screen.getByText('card body')).toBeInTheDocument();
+ const link = screen.getByRole('link', { name: /Go/i });
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ expect(link).toHaveAttribute('target', '_blank');
+ });
+});
diff --git a/src/components/pages/ToS.test.tsx b/src/components/pages/ToS.test.tsx
new file mode 100644
index 0000000..fcb6a57
--- /dev/null
+++ b/src/components/pages/ToS.test.tsx
@@ -0,0 +1,56 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { ToS, BulletPoint } from './ToS';
+
+describe('ToS page', () => {
+ it('renders the Terms of Data Use heading', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByRole('heading', { name: /Terms of Data Use/i })).toBeInTheDocument();
+ });
+
+ it('renders the Humans and Bots sections', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByRole('heading', { name: /^Humans$/ })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: /Bots.+Non-Humans/i })).toBeInTheDocument();
+ });
+
+ it('renders the page inside the PageTemplate (SideMenu visible)', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText('Home')).toBeInTheDocument();
+ expect(screen.getByText('Map')).toBeInTheDocument();
+ });
+});
+
+describe('BulletPoint', () => {
+ it('applies list-disc styling when not nested', () => {
+ const { container } = render(item);
+ const li = container.querySelector('li');
+ expect(li?.className).toContain('list-disc');
+ expect(li?.className).not.toContain('nested-list');
+ });
+
+ it('applies nested-list styling when isNested is true', () => {
+ const { container } = render(item);
+ const li = container.querySelector('li');
+ expect(li?.className).toContain('nested-list');
+ expect(li?.className).toContain('ml-12');
+ });
+
+ it('renders its children', () => {
+ render(the item);
+ expect(screen.getByText('the item')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/pages/index.test.ts b/src/components/pages/index.test.ts
new file mode 100644
index 0000000..fdde839
--- /dev/null
+++ b/src/components/pages/index.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect, vi } from 'vitest';
+
+// GalaxyMap is the only re-export that pulls in react-konva, so silence it here.
+vi.mock('react-konva', async () => {
+ const React = await import('react');
+ const passthrough = (tag: string) =>
+ ({ children, ...rest }: any) => React.createElement(tag, rest, children);
+ return {
+ Stage: passthrough('div'),
+ Layer: passthrough('div'),
+ Image: passthrough('div'),
+ Text: passthrough('span'),
+ Group: passthrough('div'),
+ Rect: passthrough('div'),
+ Line: passthrough('div'),
+ Circle: passthrough('div'),
+ };
+});
+
+vi.mock('konva', () => {
+ class Animation {
+ start() {}
+ stop() {}
+ }
+ class Text {
+ constructor(_opts: any) {}
+ width() {
+ return 10;
+ }
+ destroy() {}
+ }
+ return {
+ default: { Animation, Text },
+ __esModule: true,
+ };
+});
+
+describe('pages barrel', () => {
+ it('re-exports Home and Map', async () => {
+ const pages = await import('./index');
+ expect(pages.Home).toBeDefined();
+ expect(pages.Map).toBeDefined();
+ expect(typeof pages.Home).toBe('function');
+ expect(typeof pages.Map).toBe('function');
+ });
+
+ it('exposes exactly Home and Map', async () => {
+ const pages = await import('./index');
+ expect(Object.keys(pages).sort()).toEqual(['Home', 'Map']);
+ });
+});
diff --git a/src/components/ui/BottomFilterPanel.test.tsx b/src/components/ui/BottomFilterPanel.test.tsx
new file mode 100644
index 0000000..c132d23
--- /dev/null
+++ b/src/components/ui/BottomFilterPanel.test.tsx
@@ -0,0 +1,93 @@
+import { describe, it, expect, vi } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import BottomFilterPanel from './BottomFilterPanel';
+
+const baseProps = () => ({
+ searchTerm: '',
+ setSearchTerm: vi.fn(),
+ factions: ['Davion', 'Kurita', 'Liao'],
+ selectedFactions: [] as string[],
+ setSelectedFactions: vi.fn(),
+});
+
+describe('BottomFilterPanel', () => {
+ it('starts collapsed — the search input is not yet rendered', () => {
+ render();
+ expect(screen.queryByPlaceholderText(/Search systems/i)).toBeNull();
+ });
+
+ it('expands when the chevron area is clicked, revealing the search input', () => {
+ const { container } = render();
+ const toggle = container.querySelector('div[style*="cursor: pointer"]');
+ expect(toggle).not.toBeNull();
+
+ act(() => {
+ fireEvent.click(toggle!);
+ });
+
+ expect(screen.getByPlaceholderText(/Search systems/i)).toBeInTheDocument();
+ });
+
+ it('forwards search input changes through setSearchTerm', async () => {
+ const props = baseProps();
+ const { container } = render();
+ const toggle = container.querySelector('div[style*="cursor: pointer"]')!;
+ fireEvent.click(toggle);
+
+ const input = screen.getByPlaceholderText(/Search systems/i) as HTMLInputElement;
+ await userEvent.type(input, 'a');
+ expect(props.setSearchTerm).toHaveBeenCalledWith('a');
+ });
+
+ it('renders a removable chip for each selected faction and removes it on click', () => {
+ const props = baseProps();
+ props.selectedFactions = ['Davion'];
+ const { container } = render();
+ const toggle = container.querySelector('div[style*="cursor: pointer"]')!;
+ fireEvent.click(toggle);
+
+ // chip text should be visible
+ expect(screen.getByText('Davion')).toBeInTheDocument();
+
+ // The chip has an "x" remove child right next to the label
+ const removeButton = screen.getByText('x');
+ fireEvent.click(removeButton);
+
+ expect(props.setSelectedFactions).toHaveBeenCalledWith([]);
+ });
+
+ it('renders nothing in the chip row when no factions are selected', () => {
+ const props = baseProps();
+ const { container } = render();
+ const toggle = container.querySelector('div[style*="cursor: pointer"]')!;
+ fireEvent.click(toggle);
+
+ expect(screen.queryByText('x')).toBeNull();
+ });
+
+ it('toggles the help tooltip on the "i" icon when in mobile width', () => {
+ // Simulate narrow viewport so tooltip uses click-to-toggle semantics
+ Object.defineProperty(window, 'innerWidth', {
+ configurable: true,
+ value: 500,
+ });
+ const { container } = render();
+ const toggle = container.querySelector('div[style*="cursor: pointer"]')!;
+ fireEvent.click(toggle);
+
+ // Find the round "i" help icon by text; scan help icon spans
+ const helpIcon = screen.getByText('i');
+ // Click once → tooltip shown
+ fireEvent.click(helpIcon);
+ expect(
+ screen.getByText(/Only factions that currently have systems on the map/i)
+ ).toBeInTheDocument();
+
+ // Click again → tooltip hidden
+ fireEvent.click(helpIcon);
+ expect(
+ screen.queryByText(/Only factions that currently have systems on the map/i)
+ ).toBeNull();
+ });
+});
diff --git a/src/components/ui/BottomFilterPanel.tsx b/src/components/ui/BottomFilterPanel.tsx
index 59dbaab..d95d7e7 100644
--- a/src/components/ui/BottomFilterPanel.tsx
+++ b/src/components/ui/BottomFilterPanel.tsx
@@ -17,7 +17,7 @@ const BottomFilterPanel = ({
}) => {
const [isOpen, setIsOpen] = useState(false);
- /* ───────────── desktop breakpoint helper ───────────── */
+ /* Desktop/mobile layout mode is driven by viewport width and updated on resize. */
const [isDesktop, setIsDesktop] = useState(
typeof window !== 'undefined' && window.innerWidth >= 768
);
@@ -27,7 +27,7 @@ const BottomFilterPanel = ({
return () => window.removeEventListener('resize', onResize);
}, []);
- /* react-select helpers */
+ /* Build react-select option/value pairs from faction names for stable controlled input state. */
const options = factions.map((f) => ({ value: f, label: f }));
const selectedOpts = options.filter((o) =>
selectedFactions.includes(o.value)
@@ -48,21 +48,22 @@ const BottomFilterPanel = ({
const panel = panelRef.current;
if (!panel) return;
- // Get the current height before the change
+ // Measure the current height so height animation starts from an exact frame.
const startHeight = panel.offsetHeight;
- // Temporarily disable transitions to get new height
+ // Disable transitions temporarily so we can snapshot the target height without an
+ // intermediate animated jump.
panel.style.transition = 'none';
panel.style.height = 'auto';
const targetHeight = panel.scrollHeight;
- // Re-enable transitions
+ // Re-enable transition and restore the measured start height before animating.
requestAnimationFrame(() => {
panel.style.transition = 'height 0.5s ease';
panel.style.height = `${startHeight}px`;
- // Then trigger the new height
+ // Apply the final target height in the next frame to trigger a clean transition.
requestAnimationFrame(() => {
const expandedHeight = Math.max(targetHeight, 125);
setHeight(`${isOpen ? expandedHeight : 32}px`);
@@ -83,8 +84,8 @@ const BottomFilterPanel = ({
maxWidth: isDesktop ? '220px' : '90vw',
zIndex: 10000,
...(isDesktop
- ? { bottom: 22, left: 0 } // desktop: below/right of icon
- : { bottom: 28, right: 8, left: 'auto', transform: 'none' }), // mobile: centered below icon
+ ? { bottom: 22, left: 0 } // Desktop tooltip aligns under the help icon.
+ : { bottom: 28, right: 8, left: 'auto', transform: 'none' }), // Mobile tooltip floats centered under the icon.
};
return (
@@ -109,7 +110,7 @@ const BottomFilterPanel = ({
opacity: isOpen ? 1 : 0.5,
}}
>
- {/* chevron toggle */}
+ {/* Header trigger lets users expand/collapse the bottom filter panel. */}
: }
- {/* two-column layout */}
+ {/* Open state uses two columns: left = search, right = faction filters. */}
{isOpen && (
- {/* LEFT column — system search */}
+ {/* Left column: system search field */}
setSearchTerm(e.target.value)}
style={{
- width: isDesktop ? '50%' : '100%', // ← half width on desktop
+ width: isDesktop ? '50%' : '100%', // Desktop keeps a compact search field width.
padding: '6px 10px',
fontSize: '16px',
borderRadius: '6px',
@@ -147,12 +148,12 @@ const BottomFilterPanel = ({
outline: 'none',
backgroundColor: 'white',
color: 'black',
- margin: '0 0.25rem 0.5rem', // side + bottom space
+ margin: '0 0.25rem 0.5rem', // Add spacing so pills and controls are not crowded.
}}
/>
- {/* RIGHT column — faction select */}
+ {/* Right column: faction selector controls */}
- {/* chosen factions shown under the search bar */}
+ {/* Selected factions appear as removable chips for quick feedback and edits. */}
{selectedFactions.length > 0 && (