diff --git a/.github/instructions/webclient.instructions.md b/.github/instructions/webclient.instructions.md new file mode 100644 index 000000000..378359ad8 --- /dev/null +++ b/.github/instructions/webclient.instructions.md @@ -0,0 +1,168 @@ +--- +applyTo: "webclient/**" +--- + +# Webclient instructions + +Applies to the React/TypeScript SPA in `webclient/` (Webatrice) that connects to a Servatrice server over a WebSocket. The package is self-contained; the only thing it shares with the rest of the repo (C++ desktop/server stack) is the protobuf protocol in `../libcockatrice_protocol/`. Anything outside `webclient/` is out of scope unless a task explicitly touches the protocol. + +Canonical AI-tool instruction surface for this package — invariants, policy, and external facts. When a source comment ends with `See .github/instructions/webclient.instructions.md#`, the section with that anchor lives here. Source comments tagged `// @critical` guard cross-file invariants; do not remove them without updating the relevant section. For commands, stack, and getting-started, see [webclient/README.md](../../webclient/README.md). + +## Architecture + +### Protocol layer + +`src/generated/proto/` is buf-generated from `../libcockatrice_protocol/` (gitignored, never hand-edit). Runtime is `@bufbuild/protobuf`. `src/types/` re-exports the bindings namespaced as `Data` (raw proto), `Enriched` (UI/domain composition — proto extended with client-only sibling fields), and `App` (pure client types; no proto dependency). Consumer pattern: `import { Data, Enriched, App } from '@app/types'` then `Data.ServerInfo_User`, `Enriched.GameEntry`, `App.RouteEnum`. **UI, store, hooks, and api code must import proto types through `@app/types`, never `@app/generated` directly. `src/websocket/` is the exception and imports `@app/generated` by design.** + +Websocket protocol/transport types (`StatusEnum`, `WebSocketConnectReason`, the `*ConnectOptions` family, signal payload contexts `PendingActivationContext` / `LoginSuccessContext`, `GameEventMeta`, the `I*Request` / `I*Response` contracts, `WebClientConfig`) live separately under `@app/websocket/types` and are exposed as a single `WebsocketTypes` namespace: `import { WebsocketTypes } from '@app/websocket/types'` then `WebsocketTypes.StatusEnum`, `WebsocketTypes.LoginConnectOptions`, etc. This is the only public surface of the websocket layer's types — store, hooks, api, and UI code must access websocket types through this namespace. **Don't re-export websocket types through `Enriched`**; that namespace is strictly UI/domain composition. `@app/websocket` (the broader index) only exposes runtime values (`WebClient`, command groups, `setPendingOptions`, etc.) — not types. Inside `src/websocket/` use relative paths to specific files under `types/` (e.g. `from '../types/StatusEnum'`) rather than either alias. + +### WebSocket layer (`src/websocket/`) + +Outbound commands in `commands//`, inbound handlers in `events//`, transport in `services/`, type declarations in `types/` (request/response contracts, `StatusEnum`, `WebSocketConnectReason`, connect-options union, signal contexts — all exposed to outside consumers as the `WebsocketTypes` namespace via `@app/websocket/types`). `WebClient` is a singleton; `new WebClient(...)` is called only inside `WebClientProvider` ([webclient/src/hooks/useWebClient.tsx](../../webclient/src/hooks/useWebClient.tsx)), never at module load. + +**Layering invariant (enforced, zero violations today — keep it that way):** + +1. Containers and components call `useWebClient()` to get the `WebClient`, then `client.request..(…)`. Never import from `@app/websocket` in UI code (`@app/websocket/types` is fine — type-only); never call `new WebClient(...)` outside `WebClientProvider`. +2. `src/api/request/*RequestImpl` methods translate UI intent into `src/websocket/commands/*` calls. `src/api/response/*ResponseImpl` methods are invoked by command callbacks and event handlers and dispatch to the store. +3. Only `*.dispatch.ts` helpers inside `src/store/` and the `*ResponseImpl` classes may touch the Redux store. + +If you find yourself wanting to skip a layer (dispatching from an event handler, calling `@app/websocket` from a container, reaching into `@app/generated` from a component/store), stop. `eslint.boundaries.mjs` enforces this via the element types `api` / `components` / `containers` / `hooks` / `services` / `store` / `types` / `websocket` / `websocket-types`; `websocket-types` is deliberately a narrower surface than `websocket` so UI/store can reach protocol types without pulling in transport internals. + +### ProtobufService: request/response correlation + +- Every outbound `CommandContainer` gets a monotonically increasing `cmdId` (cast to `BigInt` for the proto field — the wire type is `int64`). A `Map` stores the response handler keyed by that ID; `processServerResponse` looks up and invokes the callback on `RESPONSE`, then deletes the entry. The `number` ↔ `BigInt` sides stay in sync because the counter never realistically exceeds `Number.MAX_SAFE_INTEGER`. +- **No timeout or retry** at the transport layer. `resetCommands()` (called on reconnect) zeros `cmdId` and clears the pending map, silently dropping any in-flight callbacks. Reconnection resilience is a caller concern. +- `sendCommand` is a no-op write if the transport isn't open — it still registers the callback, so a stale pending entry can accumulate until the next reset. +- Inbound event dispatch is extension-based: `processRoomEvent` / `processSessionEvent` / `processGameEvent` iterate the relevant registry array (entries built with `makeEntry(ext, handler)`) and invoke the first handler whose extension is set on the message. Adding a new handler means appending a `makeEntry(ExtSymbol, handler)` line to the relevant registry. + +### command-options contract (`src/websocket/services/command-options.ts`) + +Every `send*Command` call accepts an optional `CommandOptions`: + +- `responseExt?: GenExtension` — the response payload extension to unwrap on success. +- `onSuccess?: (response: R, raw: Response) => void` — called when `responseCode === RespOk`. If `responseExt` is absent, the overload becomes `() => void`. +- `onResponseCode?: { [code: number]: (raw: Response) => void }` — per-error-code handlers. +- `onError?: (code: number, raw: Response) => void` — fallback for codes not in `onResponseCode`. +- `onResponse?: (raw: Response) => void` — if set, handles the raw response and bypasses every other hook. Use when you need the full response object regardless of code. + +If none of the hooks fire for a non-OK response, `handleResponse` logs via `console.error` with the command's proto type name. Practical rule: `onSuccess` funnels into a `*ResponseImpl` method, `onError` funnels into a `*ResponseImpl` method (usually to flip connection state or show a toast), `onResponse` is rare. + +### Public API for UI (`src/api/`) + +One `*RequestImpl` / `*ResponseImpl` class per scope (session / rooms / game / admin / moderator; plus `AuthenticationRequestImpl` — auth has no inbound events). Request methods return `void` — fire-and-forget; response flows back via `command-options` callbacks → `*ResponseImpl` → store. `*ResponseImpl` classes are the only place outside `src/store/*.dispatch.ts` that calls `*Dispatch` helpers. **UI code never imports from `src/api/` directly — use `useWebClient()`.** Never call `client.response.*` from UI. + +### State (`src/store/`) + +Slices: `server/`, `rooms/`, `game/`. Consumers import through the `@app/store` barrel (`GameSelectors`, `GameDispatch`, `GameTypes`, same for `Server`/`Rooms`). **Don't deep-import from `src/store//*` — add the symbol to the barrel's `index.ts` instead.** This rule generalizes: deep paths through any `@app/*` barrel target are a smell. + +Shape notes worth knowing before you touch a reducer: + +- `game/` is deeply normalized: `games[gameId].players[playerId].zones[zoneName].cards`. Selectors are plain getters so lookups stay O(1); `createSelector` is reserved for the few that build derived lists (e.g. `getActiveGameIds`). +- Selectors return module-scope `EMPTY_ARRAY` / `EMPTY_OBJECT` constants for missing data to preserve referential equality and avoid spurious re-renders. +- `rooms/` is *partially* normalized: rooms are keyed by ID but each room also carries denormalized `gameList` / `userList` arrays. Server updates often omit those lists, so the reducer merges new metadata while preserving the existing arrays. Standing TODO to clean this up. +- `server/` is mostly flat maps keyed by username (`messages`, `userInfo`, buddy/ignore lists) plus connection state. + +### Local persistence + +Dexie (IndexedDB) holds cards, sets, tokens, known hosts, and settings; separate from Redux (persists across reloads). Stubbed globally in `setupTests.ts` so unit specs never hit a real IndexedDB. + +### UI + +Route-level containers in `containers/` (one subdir per route plus `AppShell` root and shared `Layout`); routing in `containers/App/AppShellRoutes.tsx`. Two hooks are load-bearing: **`useWebClient`** (context accessor — the only way UI code is allowed to reach the server; see the Layering invariant) and **`useAutoLogin`** (owns the once-per-session gate; see [#startup--session-invariants](#startup--session-invariants)). `WebClientProvider` ([webclient/src/hooks/useWebClient.tsx](../../webclient/src/hooks/useWebClient.tsx)) owns the singleton; `WebClientContext` is exported so integration tests can inject a pre-built `WebClient`. UI kit: MUI v9 + `@emotion`; i18n via `react-i18next` + ICU (Transifex). + +## Build pipeline and generated files + +`npm start` / `npm run build` run `prestart`/`prebuild` hooks: `proto:generate` followed by `node prebuild.js`. `prebuild.js` writes `src/server-props.json` (git SHA), merges `src/**/*.i18n.json` into `src/i18n-default.json` (**throws on duplicate keys** — namespace your i18n keys), and copies country flags from `../cockatrice/resources/countries`. + +| File | Tracked? | Regenerate with | +|---|---|---| +| `src/generated/proto/**` | Gitignored | `npm run proto:generate` | +| `src/server-props.json` | Gitignored | `npm start` / `npm run build` (prebuild writes it) | +| `src/i18n-default.json` | **Committed** | `npm run translate` (or the prebuild hook) | + +`.env.development`, `.env.production`, `.env.test` exist but are empty. No `import.meta.env` configuration surface; server URLs resolve through the login UI / `server-props.json`. + +## Testing + +Vitest + Testing Library + jsdom. [webclient/src/setupTests.ts](../../webclient/src/setupTests.ts) registers jest-dom matchers and installs a global Dexie mock. + +Unit specs run under [webclient/vite.config.ts](../../webclient/vite.config.ts) with `test.isolate: true`: every spec file gets a fresh module graph, but tests **within the same file share it**. `vi.clearAllMocks()` (clears call logs) runs in the global `afterEach` and is safe. **Never add `vi.resetAllMocks()` to `setupTests.ts`** — it resets `vi.fn()` instances created inside `vi.mock(...)` factories at file load, breaking any spec that mocks something once (e.g. `store.dispatch`) and expects it to persist across tests in the file. + +Integration specs run under [webclient/vitest.integration.config.ts](../../webclient/vitest.integration.config.ts) via `npm run test:integration` — slower; exercise the wired-up `WebClient` against fakes in `src/__test-utils__/`. + +**Globals that leak within a file.** `vi.restoreAllMocks()` only restores `vi.spyOn` targets. Bare `Object.defineProperty` writes (e.g. on `window.location`) and global reassignments (e.g. `globalThis.WebSocket = ...`) leak between tests in the same file — `setupTests.ts` does not auto-restore them. Use `withMockLocation` from [webclient/src/__test-utils__/globalGuards.ts](../../webclient/src/__test-utils__/globalGuards.ts) for scoped overrides that clean up after themselves. + +**Shared scaffolding.** [webclient/src/__test-utils__/](../../webclient/src/__test-utils__/) provides render helpers, a mock-client builder, and global guards. Prefer these over hand-rolling providers — the integration suite depends on injecting pre-built `WebClient` instances through them. Store slices have co-located `__mocks__/fixtures.ts` files exposing `make*` factories that build protobuf messages via `create(Schema, overrides)`; reuse them instead of hand-rolling proto objects. + +`npm run golden` (lint + unit + integration) is the CI gate — run it before declaring work done. + +## Protocol changes + +When a task edits `.proto` files in `../libcockatrice_protocol/`: + +1. Run `npm run proto:generate`. +2. Update any command / event / `*RequestImpl` / `*ResponseImpl` code that consumes the changed messages. +3. Commit consumer changes only — `src/generated/proto/**` is gitignored and must not be committed. + +--- + +## Domain Knowledge + +Facts that can't be read off the code — external systems (Servatrice protocol, Protobuf-ES runtime, browser WebSocket semantics) and invariants the code relies on but cannot itself express. + +### Initialization order + +Protobuf-ES maps proto `int64` / `uint64` fields to native `BigInt`. `BigInt.prototype` has no `toJSON`, so `JSON.stringify` throws on any state that contains one — which Redux DevTools, structured logging, and React error-boundary dumps all do. [webclient/src/polyfills.ts](../../webclient/src/polyfills.ts) installs a `BigInt.prototype.toJSON` that returns `this.toString()`, coercing to string on serialize. + +Coercion is one-way: `JSON.parse` does not round-trip back to `BigInt`. That is acceptable because in-memory state still holds real `BigInt`s; only serialized surfaces (devtools, logs) see the coerced form. + +The polyfill must execute before any module creates the store, or the first devtools dump throws. Enforced by making `./polyfills` the first import in [webclient/src/index.tsx](../../webclient/src/index.tsx) and [webclient/src/setupTests.ts](../../webclient/src/setupTests.ts). + +### Startup / session invariants + +Product requirement: **auto-login runs at most once per JS session, and logout within the same session does NOT re-trigger it.** Only a full page refresh does. This matches the Cockatrice desktop client. + +The gate lives at module scope in [webclient/src/hooks/useAutoLogin.ts](../../webclient/src/hooks/useAutoLogin.ts) as `autoLoginGate.hasChecked`. It flips to `true` after the startup check completes, regardless of whether the check actually fired a login — so a check that determined "don't auto-connect" (preference off, no saved password, etc.) still latches the gate. The gate is exported as a mutable object so integration tests can reset it without `vi.resetModules()`. + +`useAutoLogin` consults settings via `getSettings()` (one-shot), not by subscribing to `settingsStore`. Editing the persisted auto-connect preference is a preference write, not a login signal. + +### Data structure invariants + +`Enriched.Room` and `Enriched.GameEntry` compose a raw proto (`info`) with client-side sibling fields. The TypeScript types cannot distinguish which fields stay fresh and which go stale, so this is a convention: + +- **`info` is a wire snapshot at one point in time.** For `Room` it's the last `UPDATE_ROOMS` / `JOIN_ROOM` payload; for `GameEntry` it's the `Event_GameJoined` payload. +- **Fields on `info` that evolve via later events immediately go stale.** Read the sibling, never `info.*`: + +| Type | Stale on `info` | Read instead | +|---|---|---| +| `Room` | `info.gameList` | `room.games` | +| `Room` | `info.userList` | `room.users` | +| `Room` | `info.gametypeList` | `room.gametypeMap` | +| `GameEntry` | `info.started` | `game.started` | +| `GameEntry` | `info.activePlayerId` etc. | top-level twin fields | + +Adding a new field that updates via events means adding a top-level twin in [webclient/src/types/enriched.ts](../../webclient/src/types/enriched.ts) and never reading `info.` after the initial snapshot. + +### Reducer merge rules + +Servatrice's `UPDATE_ROOMS` event carries room metadata only: the repeated `gameList` / `userList` / `gametypeList` collections on each `ServerInfo_Room` may be absent or stale. The reducer at [webclient/src/store/rooms/rooms.reducer.tsx](../../webclient/src/store/rooms/rooms.reducer.tsx) replaces `info`, `gametypeMap`, and `order` on existing rooms but preserves the normalized `games` and `users` maps, which are maintained by their own events (`updateGames`, `userJoined`, `userLeft`). + +### Shared store pattern + +`createSharedStore` in [webclient/src/hooks/useSharedStore.ts](../../webclient/src/hooks/useSharedStore.ts) exposes two surfaces with different semantics. Pick the right one per caller: + +- **`subscribe` / `getSnapshot` (via `useSharedStore`)** — reactive. The component re-renders on every store update. Use from inside render. +- **`whenReady()`** — one-shot. Resolves with the first loaded value, then never fires again. Use from code that must read the loaded value exactly once and must NOT re-run on later updates (notably, startup orchestrators reading persisted preferences). + +Subscribing in a startup orchestrator turns a later user action (ticking a preference) into a re-evaluation of startup logic, which is almost always wrong. + +### Protocol quirks + +Servatrice-side behavior the client has to accommodate: + +- **`ServerOptions` is a bitmask.** [webclient/src/websocket/utils/passwordHasher.ts](../../webclient/src/websocket/utils/passwordHasher.ts) `passwordSaltSupported` uses `&`, not `===`. Don't "fix" it. +- **System-injected user messages can omit the username** (e.g. ban notifications where the target is the current user, or server announcements). [webclient/src/store/common/normalizers.ts](../../webclient/src/store/common/normalizers.ts) `normalizeUserMessage` handles this at the dispatch layer so the store always holds a clean user-facing string. + +### WebSocket lifecycle + +A failed `WebSocket` connect fires both `onerror` and `onclose`. `onerror` runs first with the richer status; [webclient/src/websocket/services/WebSocketService.ts](../../webclient/src/websocket/services/WebSocketService.ts) guards the `onclose` handler with `hasReportedError` so the generic "Connection Closed" doesn't overwrite the specific "Connection Failed". The flag clears on `onopen` and at the end of each `onclose` cycle. diff --git a/webclient/.env b/webclient/.env index 8592b28b4..be70fd388 100644 --- a/webclient/.env +++ b/webclient/.env @@ -1 +1,2 @@ # Future template for server admin configuration +NODE_OPTIONS=--max-old-space-size=8192 \ No newline at end of file diff --git a/webclient/CLAUDE.md b/webclient/CLAUDE.md deleted file mode 100644 index 8c1b59078..000000000 --- a/webclient/CLAUDE.md +++ /dev/null @@ -1,170 +0,0 @@ -# CLAUDE.md - -Guidance for Claude Code when working inside `webclient/` — the React/TypeScript SPA (Webatrice) that connects to a Servatrice server over a WebSocket. It is a self-contained application; the only thing it shares with the rest of the repo (C++ desktop/server stack) is the protobuf protocol in `../libcockatrice_protocol/`. Anything outside `webclient/` is out-of-scope unless a task explicitly touches the protocol. - -All commands below are run from this directory. - -## Commands - -```bash -npm start # Vite dev server (runs proto:generate + prebuild.js first) -npm run build # production build (same prebuild hooks) -npm test # vitest run (one-shot) -npm run test:watch # vitest watch -npm run lint # eslint src/ -npm run lint:fix -npm run golden # lint + test — the CI-equivalent gate to run before declaring work done -npm run proto:generate # `npx buf generate` — regenerates TS bindings into src/generated/proto -``` - -Single test file: `npx vitest run path/to/file.spec.ts`. Filter by name: `npx vitest run -t "partial test name"`. - -The dev server has `server.open: true`, so `npm start` pops a browser tab automatically. - -## Architecture - -The webclient is a Redux Toolkit + RxJS app. Its defining abstraction is a layered WebSocket client that speaks the Cockatrice protobuf protocol to Servatrice. Understanding the layering is essential before editing anything under `src/websocket/`, `src/api/`, or `src/store/`. - -### Protocol layer - -- **`src/generated/proto/`** — auto-generated from `../libcockatrice_protocol/**/*.proto` by `buf` (see `buf.gen.yaml`). Never edit by hand. Runtime is `@bufbuild/protobuf` (Protobuf-ES); the codebase was recently migrated off the older `protobufjs`, so if you find any stray references to the old runtime, they're bugs. -- **`src/types/` is the only public surface for generated code.** `src/types/data.ts` hand-rolls an `export *` barrel over every proto file that consumers use, and `src/types/index.ts` re-exports it as `Data`, plus `Enriched` (protocol types extended with client-only fields) and `App` (pure client types). Import as `import { Data, Enriched } from '@app/types'` and use `Data.Command_Login`, `Data.ServerInfo_User`, etc. **Never import directly from `@app/generated/proto/*` outside `src/types/`.** When a new proto file starts being consumed, add an `export *` line to `src/types/data.ts` — there is a standing TODO to replace this rollup with a protobuf-es plugin. - -### WebSocket layer (`src/websocket/`) - -A strict inbound/outbound split sits on top of a transport core: - -- **`services/`** — transport: `WebSocketService` (socket lifecycle), `ProtobufService` (encode/decode + request/response correlation), `KeepAliveService` (ping/pong), `command-options` (per-command response config). This layer has no knowledge of Redux. -- **`commands/`** — *outbound*. Organised by scope (`session/`, `room/`, `game/`, `admin/`, `moderator/`). Each command builds a protobuf request and hands it to `ProtobufService.send{Session,Room,Game,Admin,Moderator}Command` along with a `CommandOptions` describing how to handle the response. -- **`events/`** — *inbound*. Handlers for server-pushed events, same scopes. They translate protobuf events into calls on the persistence layer. -- **`persistence/`** — the **only** bridge from the websocket layer into app state. `SessionPersistence`, `RoomPersistence`, `GamePersistence`, `AdminPersistence`, `ModeratorPersistence` dispatch Redux actions and/or write to Dexie. -- **`WebClient.ts`** — singleton facade that wires the services, commands, events, and persistence together. - -**Layering invariant (enforced on this branch, not aspirational):** - -1. Containers and components call `src/api/*` services — never `src/websocket/*` directly. -2. Commands and event handlers call `*Persistence` methods — never `store.dispatch` directly. -3. Only `*.dispatch.ts` helpers inside `src/store/` and persistence code may touch the Redux store. - -If you find yourself wanting to skip a layer (dispatching from an event handler, calling a command from a container, reaching into `src/generated/proto/` from a component), stop — the refactor on `webclient-websocket-layer` exists precisely to eliminate those shortcuts. There are currently zero violations; keep it that way. - -### ProtobufService: request/response correlation - -- Every outbound `CommandContainer` gets a monotonically increasing `cmdId` (cast to `BigInt` for the proto field). A `Map` stores the response handler keyed by that ID; when `ServerMessage.RESPONSE` arrives, `processServerResponse` looks up and invokes the callback, then deletes the entry. -- There is **no timeout or retry**. `resetCommands()` (called on reconnect) zeros `cmdId` and clears the pending map, silently dropping any in-flight callbacks. Code that needs reconnection resilience has to handle it at a higher layer. -- `sendCommand` is a no-op write if the transport isn't open — it still registers the callback, so a stale pending entry can accumulate until the next reset. -- Inbound event dispatch is extension-based: `processRoomEvent` / `processSessionEvent` / `processGameEvent` iterate `RoomEvents` / `SessionEvents` / `GameEvents` (tuples of `[extension, handler]`) and invoke the first handler whose extension is set on the message. Adding a new event handler means appending to those arrays. - -### command-options contract (`src/websocket/services/command-options.ts`) - -Every `send*Command` call accepts an optional `CommandOptions`: - -- `responseExt?: GenExtension` — the response payload extension to unwrap on success. -- `onSuccess?: (response: R, raw: Response) => void` — called when `responseCode === RespOk`. If `responseExt` is absent, the overload becomes `() => void`. -- `onResponseCode?: { [code: number]: (raw: Response) => void }` — per-error-code handlers. -- `onError?: (code: number, raw: Response) => void` — fallback for codes not in `onResponseCode`. -- `onResponse?: (raw: Response) => void` — if set, it handles the raw response and bypasses every other hook. Use this when you need the full response object regardless of code. - -If none of the hooks fire for a non-OK response, `handleResponse` logs the failure via `console.error` with the command's proto type name. The practical rule: `onSuccess` funnels into persistence, `onError` funnels into persistence (usually to flip connection state or show a toast), and `onResponse` is rare. - -### Public API for UI (`src/api/`) - -Thin service wrappers (`AuthenticationService`, `SessionService`, `RoomsService`, `GameService`, `ModeratorService`, `AdminService`) that expose websocket commands to UI code. A few things to know: - -- **All command methods are `static` and return `void`.** They're fire-and-forget — the response flows back through the `command-options` callbacks plumbed inside the command itself, into persistence, into the store. Don't try to await them. -- A handful of methods return `boolean` (e.g. `AuthenticationService.isConnected`, `isModerator`) — those are pure sync predicates, not command sends. -- Files use the `.tsx` extension even though they contain no JSX. That's a leftover convention; don't "fix" it. - -### State (`src/store/`) - -Redux Toolkit store (`store.ts`, `rootReducer.ts`) split by feature. Each slice follows the same file layout: - -- `*.actions.ts` — action creators -- `*.reducer.ts` — slice reducer -- `*.selectors.ts` — selectors (mostly plain getters; `createSelector` only for derived lists) -- `*.dispatch.ts` — dispatch helpers called by the persistence layer -- `*.interfaces.ts` / `*.types.ts` — state shape and enums - -Slices: `server/`, `rooms/`, `game/`, plus shared `actions/` and `common/` helpers (`SortUtil`, `normalizers`). Consumers import through the `@app/store` barrel — `GameSelectors`, `GameDispatch`, `GameTypes`, and the same prefixed set for `Server`/`Rooms`. **Don't deep-import from `src/store/game/game.selectors.ts` etc.** — go through `@app/store`. - -Shape notes worth knowing before you touch a reducer: - -- `game/` is deeply normalized: `games[gameId].players[playerId].zones[zoneName].cards`. Selectors are plain getters so lookups stay O(1); `createSelector` is reserved for the few that build derived lists (e.g. `getActiveGameIds`). -- Selectors return module-scope `EMPTY_ARRAY` / `EMPTY_OBJECT` constants for missing data to preserve referential equality and avoid spurious re-renders. -- `rooms/` is *partially* normalized: rooms are keyed by ID, but each room also carries denormalized `gameList` / `userList` arrays. Server updates often omit those lists, so the reducer merges new metadata while preserving the existing arrays. There is a standing TODO to clean this up. -- `server/` is mostly flat maps keyed by username (`messages`, `userInfo`, buddy/ignore lists) plus connection state. - -### Local persistence (`src/services/dexie/`) - -IndexedDB storage via Dexie for cards, sets, tokens, known hosts, and settings. DTOs live in `DexieDTOs/`. This is separate from the Redux store — used for data that should survive a reload (card database, user settings, host list). Dexie is not mocked in unit tests; code that writes to Dexie is typically exercised only in integration paths. - -### UI - -- **`containers/`** — route-level, Redux-connected. Top-level routes: `App`, `Initialize`, `Login`, `Server`, `Room`, `Game`, `Player`, `Decks`, `Account`, `Logs`, `Layout`, `Unsupported`. Routing lives in `containers/App/AppShellRoutes.tsx`. -- **`components/`** — presentational, mostly unconnected. -- **`forms/`** — `react-final-form` forms (e.g. `LoginForm`). -- **`dialogs/`** — MUI dialogs. -- **`hooks/`** — shared hooks (e.g. `useAutoConnect`). -- **`i18n.ts` / `i18n-backend.ts`** — `react-i18next` + ICU; translations managed via Transifex. -- UI kit: MUI v7 (`@mui/material`, `@emotion`). - -### Path aliases - -`tsconfig.json` defines the following (resolved at build time by `vite-tsconfig-paths`): - -``` -@app/api @app/components @app/containers @app/dialogs -@app/forms @app/hooks @app/images @app/services -@app/store @app/types @app/websocket @app/generated/* -``` - -Prefer these in new code over relative imports when crossing top-level directory boundaries. Deep paths into a barrel target (e.g. `@app/store/game/...`) are a smell — add the symbol to the relevant `index.ts` barrel instead. - -### End-to-end data flow - -User action in a container → `src/api/*Service` → `src/websocket/commands/*` → `ProtobufService.send*Command` → socket. -Server reply/event → `src/websocket/events/*` (or the `command-options` callback on the original command) → `src/websocket/persistence/*` → `*.dispatch.ts` helpers → Redux / Dexie → selectors → container re-render. - -## Build pipeline and generated files - -`npm start` and `npm run build` both run `prestart`/`prebuild` hooks that invoke `proto:generate` and then `node prebuild.js`. `prebuild.js` does three things: - -1. Copies shared country flag assets from `../cockatrice/resources/countries` into `src/images/countries`. -2. Writes `src/server-props.json` containing `REACT_APP_VERSION` = current `git rev-parse HEAD`. -3. Walks `src/**/*.i18n.json`, merges them into `src/i18n-default.json`, and **throws on duplicate keys** (`i18n key collision: ${key}`). Namespace your i18n keys — collisions fail the build. - -Files you should never edit by hand (all auto-generated, all committed): - -- `src/generated/proto/**` -- `src/i18n-default.json` -- `src/server-props.json` - -If `npm start` seems to be ignoring a new `.i18n.json` file or a fresh proto, run `npm run proto:generate && node prebuild.js` directly — the hooks only fire on `start`/`build`, not on `test` or `lint`. - -`.env.development`, `.env.production`, and `.env.test` exist but are empty. There is currently no env-var configuration surface; server URLs and the like are resolved through the login UI / `server-props.json`, not `import.meta.env`. - -## Testing - -Vitest + Testing Library + jsdom; `setupTests.ts` registers jest-dom matchers. - -**Vitest runs with `test.isolate: false`.** Every spec file in a worker shares the same module graph, so `vi.mock(...)` factories and any mocks they create persist across tests. Consequences: - -- The global `afterEach` in `setupTests.ts` calls `vi.clearAllMocks()` + `vi.restoreAllMocks()` + `vi.useRealTimers()`. It deliberately does **not** call `vi.resetAllMocks()`, because that would reset the implementations of `vi.fn()` instances created inside `vi.mock(...)` factories and break every spec that mocks `store.dispatch` once at file load. -- A test that installs a custom `mockReturnValue` / `mockImplementation` should not assume the next test resets it — either overwrite it or rely on `clearAllMocks` wiping only call histories. -- Always use real timers at the end of a test that switched to fake ones; the global teardown will catch leaks, but relying on it is fragile across files. - -Other conventions: - -- **Fixtures.** Store slices have co-located `__mocks__/fixtures.ts` files (notably `src/store/game/__mocks__/fixtures.ts`) exposing factories like `makeCard`, `makeGameEntry`, `makePlayerProperties`, `makeState`. They build protobuf messages via `create(Schema, overrides)`. Reuse them in new tests instead of hand-rolling proto objects. -- **Websocket mocks.** `src/websocket/__mocks__/` holds shared mock builders (e.g. `makeMockWebSocket`, `makeWebClientMock`, `makeSessionPersistenceMock`). Command and event specs install these with `vi.mock(...)` at the top of the file. -- **Slice tests are per-concern.** Each slice ships parallel `*.actions.spec.ts`, `*.reducer.spec.ts`, `*.selectors.spec.ts`, and `*.dispatch.spec.ts` files; tests don't cross concerns. - -`npm run golden` (lint + test) is the CI gate — run it before declaring work done. - -## Protocol changes - -When a task requires editing `.proto` files in `../libcockatrice_protocol/`, run `npm run proto:generate` afterwards, and: - -1. If the change introduces a new proto *file* that code outside `src/types/` needs to consume, add an `export *` line for it in `src/types/data.ts`. -2. Update any command/event/persistence code that consumes the changed messages. -3. Commit the regenerated files under `src/generated/proto/`. diff --git a/webclient/README.md b/webclient/README.md index 436ab4fad..774b84ffe 100644 --- a/webclient/README.md +++ b/webclient/README.md @@ -1,73 +1,67 @@ +# Webatrice + +The Cockatrice web client — a React/TypeScript SPA that connects to a Servatrice server over a WebSocket. + ## Application Architecture -![Application Architecture](architecture.png?raw=true "Application Architecture") -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). +![Application Architecture](architecture/simple.png?raw=true "Application Architecture") -## Available Scripts +For the full set of diagrams (detailed layer map + command/response/event sequence) and the `npm run diagram` scripts that regenerate them, see [architecture/](architecture/). For prose — WebSocket layering, Redux store shape, test conventions — see [.github/instructions/webclient.instructions.md](../.github/instructions/webclient.instructions.md). -In the project directory, you can run: +## Stack -### `npm start` +React 19 + TypeScript, built with [Vite](https://vite.dev/) 8. State via Redux Toolkit + RxJS, UI via MUI v9, tests via Vitest, protobuf bindings generated by [buf](https://buf.build/) into Protobuf-ES. -Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser. +## Prerequisites -The page will reload if you make edits.
-You will also see any lint errors in the console. +- Node.js and npm +- Run every command below from the `webclient/` directory -### `npm test` +## Getting started -Launches the test runner in the interactive watch mode.
-See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. +```bash +npm install +npm start +``` -### `npm run build` +`npm start` boots the Vite dev server and opens a browser tab at [http://localhost:5173](http://localhost:5173) automatically (configured via `server.open` in `vite.config.ts`). The first start runs `proto:generate` and `prebuild.js` via the `prestart` hook, so give it a moment. -Builds the app for production to the `build` folder.
-It correctly bundles React in production mode and optimizes the build for the best performance. +## Scripts -The build is minified and the filenames include the hashes.
-Your app is ready to be deployed! +### Dev & build -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. +- `npm start` — start the Vite dev server (runs `proto:generate` + `prebuild.js` first via `prestart`) +- `npm run build` — production build into `build/` (also runs the prebuild hooks) +- `npm run preview` — serve the built `build/` output locally to smoke-test a production build -### `npm run eject` +### Tests -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** +- `npm test` — one-shot Vitest run (unit specs) +- `npm run test:watch` — Vitest in watch mode +- `npm run test:integration` — integration specs via `vitest.integration.config.ts` +- `npm run test:coverage` / `npm run test:integration:coverage` — the above with v8 coverage -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. +### Quality -Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. +- `npm run lint` / `npm run lint:fix` — ESLint over `src/` +- `npm run golden` — `lint` + `test` + `test:integration`; the CI-equivalent gate to run before declaring work done -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. +### Codegen & i18n -## Learn More +- `npm run proto:generate` — regenerate Protobuf-ES bindings from `../libcockatrice_protocol` via `buf generate` +- `npm run translate` — re-run the i18n merge only (`prebuild.js -i18nOnly`) -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). +## Generated files -To learn React, check out the [React documentation](https://reactjs.org/). +Produced by `proto:generate` and `prebuild.js` on every `npm start` / `npm run build`. Don't edit them by hand: -## To-Do List +| File | Tracked? | Notes | +|---|---|---| +| `src/generated/proto/**` | Gitignored | Protobuf bindings. Regenerate with `npm run proto:generate`; only appears after a first local run. | +| `src/server-props.json` | Gitignored | Build metadata including the current git SHA. Written by `prebuild.js`; only appears after a first local run. | +| `src/i18n-default.json` | **Committed** | Merged i18n catalog. Regenerate with `npm run translate` and commit whenever it changes. | -1) RefreshGuard modal - - there is no browser support for displaying custom output to window.onbeforeunload - - we should also display a custom modal explaining why they shouldnt refresh or navigate from the site - - ideally, the custom popup can be synced with the alert, so when the alert is closed, the modal closes too +## Further reading -2) Disable AutoScrollToBottom when the user has scrolled up - - when the user scrolls back to bottom, it should renable - - renable after a period of inactivity (3 minutes?) - -3) Figure out how to type components w/ RouteComponentProps - - Component> - -4) clear input onSubmit - -5) figure out how to reflect server status changes in the ui - -6) Account page - -7) Register/Reset Password forms - -8) Message User - -9) Main Nav scheme +- [.github/instructions/webclient.instructions.md](../.github/instructions/webclient.instructions.md) — architecture deep dive, conventions, and domain-knowledge invariants for working in this directory (the canonical AI-tool instruction surface for this package) +- [Vite docs](https://vite.dev/guide/) · [React docs](https://react.dev/) · [Vitest docs](https://vitest.dev/) diff --git a/webclient/architecture.png b/webclient/architecture.png deleted file mode 100644 index 0226eb201..000000000 Binary files a/webclient/architecture.png and /dev/null differ diff --git a/webclient/architecture/README.md b/webclient/architecture/README.md new file mode 100644 index 000000000..55a9b9d64 --- /dev/null +++ b/webclient/architecture/README.md @@ -0,0 +1,70 @@ +# Webatrice architecture diagrams + +Three views of the same architecture at different zoom levels. The `.mmd` files are the source of truth — edit them and re-render when the architecture changes. The `.png` files are committed so the README and GitHub file view render everywhere, including offline. + +For the prose counterpart and conventions, see [../.github/instructions/webclient.instructions.md](../../.github/instructions/webclient.instructions.md) (the canonical AI-tool instruction surface for this package). + +## When to look at which + +| Diagram | Use it when | +|---|---| +| **[simple](simple.mmd)** | You need a mental model of the request/response loop in ten seconds. Good for onboarding. | +| **[detailed](detailed.mmd)** | You're making a structural change and want to see every module, which layer it belongs to, and how data moves between them. | +| **[flow](flow.mmd)** | You're debugging a specific round-trip and need to see the runtime order. Shows the `cmdId`-correlated response path vs. the extension-dispatched event path, plus the "no timeout, no retry" caveat. | + +## Simple — high-level flow + +![Simple architecture](simple.png) + +Application on the left, Servatrice on the right, two-lane racetrack in between. The top lane is outbound (`client.request.*` → `Commands`), the bottom lane is inbound (`Events · Responses` → `client.response.*`), and both lanes ride the same WebSocket. Redux hangs off Application as its in-memory store; IndexedDB sits under Servatrice as the browser-side persistent store reached from hooks via Dexie. Both are stores, both sit outside the racetrack. + +**Color = role:** + +- Blue — application code (UI, hooks, API seams, WebClient) +- Purple — transport (WebSocket layer, services) +- Amber — state / data stores (Redux, protocol types) +- Gray — external systems (Servatrice, IndexedDB) + +## Detailed — layers & dependencies + +![Detailed architecture](detailed.png) + +Every meaningful module in the webclient, arranged as a three-lane racetrack: outbound (`src/api/request/` → `commands/`) on top, transport (`WebClientProvider` → `WebClient` → `services/`) in the middle, inbound (`events/` → `src/api/response/`) on the bottom. Application bookend on the left holds UI, hooks, Redux store, and the Dexie persistence pair; Servatrice sits on the right. The protocol satellite (`src/types/` + `src/generated/proto/`) is drawn below with dashed edges up to the modules it types — it's cross-cutting, not on the flow path. Same four-role palette as the simple diagram. + +Load-bearing invariants (enforced on `webclient-websocket-layer`; keep it that way): + +- **UI never imports `@app/websocket` or `@app/api`** — always go through `useWebClient()`. +- **Only `src/types/` imports from `@app/generated`** — everywhere else uses `Data` / `Enriched` / `App`. +- **Only `*.dispatch.ts` helpers and `*ResponseImpl` classes call `store.dispatch`** — the API response layer is the single inbound seam into Redux. + +## Flow — command → response → event round-trip + +![Sequence: join room](flow.png) + +Scenario: user joins a room. The sequence shows the outbound command path (steps 1–6), the correlated response path matched by `cmdId` in `ProtobufService`'s pending map (steps 7–10), and an unsolicited server event dispatched by proto-extension match against the event registry in `processRoomEvent` / `processSessionEvent` / `processGameEvent` (steps 11–15). + +Read the footnote: `ProtobufService` has no timeout and no retry, and `resetCommands()` on reconnect silently drops in-flight callbacks. Code that needs reconnection resilience has to handle it at a higher layer. + +## Rendering + +npm scripts are defined in [../package.json](../package.json) — no separate build step, no added runtime dependency (everything runs via `npx`). + +```bash +# from the webclient/ directory: + +npm run diagram # render all three (simple + detailed + flow) +npm run diagram:simple # render just simple.png +npm run diagram:detailed # render just detailed.png +npm run diagram:flow # render just flow.png +``` + +Under the hood each command is: + +```bash +npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc \ + -i architecture/.mmd -o architecture/.png -b white -s 2 +``` + +`-s 2` renders at 2× scale so the PNG stays crisp on high-DPI displays; `-b white` gives the diagrams a light-mode background that looks right in both GitHub's light and dark themes. + +If `mmdc` fails locally (it spawns headless Chromium — some sandboxed environments block that), paste the `.mmd` contents into [mermaid.live](https://mermaid.live) and export to PNG. The `.mmd` sources remain canonical either way. diff --git a/webclient/architecture/detailed.mmd b/webclient/architecture/detailed.mmd new file mode 100644 index 000000000..112aca658 --- /dev/null +++ b/webclient/architecture/detailed.mmd @@ -0,0 +1,123 @@ +--- +config: + layout: elk + theme: base + themeVariables: + background: "#ffffff" + primaryColor: "#ffffff" + primaryBorderColor: "#1f2937" + primaryTextColor: "#0b1220" + lineColor: "#1f2937" + textColor: "#0b1220" + edgeLabelBackground: "#ffffff" + fontSize: "20px" + clusterBkg: "#fafafa" + clusterBorder: "#9ca3af" + flowchart: + htmlLabels: true + curve: basis + nodeSpacing: 60 + rankSpacing: 90 +--- +flowchart LR + %% ========================================================= + %% Left bookend — browser-side Application + %% ========================================================= + subgraph LBE["Application"] + direction TB + UI["UI
containers · components
forms · dialogs
"] + Hooks["hooks/
useWebClient · useAutoLogin
useSettings · useKnownHosts
"] + Store[("@app/store
server · rooms · game
actions · common
")] + DTOs["dexie DTOs
Card · Host · Set
Setting · Token
"] + IDB[("IndexedDB")] + end + + %% ========================================================= + %% Racetrack — three lanes: outbound / transport / inbound + %% ========================================================= + subgraph RACE[" "] + direction TB + + subgraph TOP["Outbound lane"] + direction LR + Req["src/api/request/
Authentication · Session · Rooms
Game · Admin · Moderator
"] + Cmds["commands/
session · room · game
admin · moderator
"] + end + + subgraph MID["Transport"] + direction LR + Provider["WebClientProvider"] + WC[["WebClient
singleton · request · response"]] + Svc["services/
ProtobufService · WebSocketService
KeepAliveService · command-options
"] + end + + subgraph BOT["Inbound lane"] + direction LR + Evts["events/
session · room · game"] + Res["src/api/response/
Session · Room · Game
Admin · Moderator
"] + end + end + + %% ========================================================= + %% Right bookend — Servatrice + %% ========================================================= + Srv[("Servatrice")] + + %% ========================================================= + %% Protocol satellite — cross-cutting types + %% ========================================================= + subgraph PROTO["Protocol (cross-cutting)"] + direction LR + Types["src/types/
Data · Enriched · App"] + Gen["src/generated/proto/
@bufbuild/protobuf"] + end + + %% ========================================================= + %% UI-side wiring + %% ========================================================= + UI --> Hooks + Hooks -- "useWebClient()" --> Provider + Provider --> WC + UI -- "selectors (read)" --> Store + Hooks --> DTOs + DTOs <--> IDB + + %% ========================================================= + %% Outbound — request goes up through the top lane to Srv + %% ========================================================= + WC --> Req + Req --> Cmds + Cmds --> Svc + Svc -- "frames" --> Srv + + %% ========================================================= + %% Inbound — Srv comes back through services, splits to + %% cmdId response (direct) and event-registry dispatch + %% ========================================================= + Srv -- "frames" --> Svc + Svc --> Evts + Svc -- "response by cmdId" --> Res + Evts --> Res + Res -- "dispatch" --> Store + + %% ========================================================= + %% Protocol edges — dashed, cross-cutting + %% ========================================================= + Req -.-> Types + Res -.-> Types + Cmds -.-> Types + Evts -.-> Types + Types --> Gen + + %% ========================================================= + %% Palette — four roles + %% ========================================================= + classDef app fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef transport fill:#ede9fe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef store fill:#fde68a,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef external fill:#e5e7eb,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + + class UI,Hooks,DTOs,Provider,WC app + class Req,Cmds,Svc,Evts,Res transport + class Store,Types,Gen store + class Srv,IDB external diff --git a/webclient/architecture/detailed.png b/webclient/architecture/detailed.png new file mode 100644 index 000000000..16d212392 Binary files /dev/null and b/webclient/architecture/detailed.png differ diff --git a/webclient/architecture/flow.mmd b/webclient/architecture/flow.mmd new file mode 100644 index 000000000..030800782 --- /dev/null +++ b/webclient/architecture/flow.mmd @@ -0,0 +1,41 @@ +%%{init: {"theme": "base", "themeVariables": {"background": "#ffffff", "primaryColor": "#ffffff", "primaryBorderColor": "#1f2937", "primaryTextColor": "#0b1220", "lineColor": "#1f2937", "textColor": "#0b1220", "signalColor": "#1f2937", "signalTextColor": "#0b1220", "actorBkg": "#ffedd5", "actorBorder": "#1f2937", "actorTextColor": "#0b1220", "actorLineColor": "#1f2937", "sequenceNumberColor": "#ffffff", "noteBkgColor": "#fef3c7", "noteBorderColor": "#1f2937", "noteTextColor": "#0b1220", "labelBoxBkgColor": "#ffffff", "labelTextColor": "#0b1220"}, "sequence": {"showSequenceNumbers": true, "mirrorActors": false, "messageFontSize": "13px", "actorFontSize": "13px", "noteFontSize": "12px", "actorMargin": 14, "boxMargin": 6, "boxTextMargin": 3, "noteMargin": 8, "messageMargin": 48, "diagramMarginX": 8, "diagramMarginY": 8, "width": 120}}}%% +sequenceDiagram + autonumber + + participant C as Application + participant RQ as API Request + participant CMD as Command + participant PB as Protobuf + participant WS as WebSocket + participant S as Servatrice + participant EV as Event + participant RS as API Response + + rect rgb(219, 234, 254) + Note over C,S: Request — user action → command + C->>RQ: joinRoom(roomId) + RQ->>CMD: build RoomCommand + CMD->>PB: sendRoomCommand(cmd, onSuccess/onError) + PB->>PB: assign cmdId,
register callback + PB->>WS: send(bytes) + WS->>S: Command + end + + rect rgb(254, 243, 199) + Note over S,RS: Response — correlated by cmdId + S-->>WS: Response (cmdId) + WS-->>PB: processServerResponse + PB->>RS: onSuccess(response) + RS->>RS: dispatch → Redux + end + + rect rgb(220, 252, 231) + Note over S,RS: Event — no cmdId — dispatched by extension + S-->>WS: Event + WS-->>PB: processRoomEvent + PB->>EV: pick handler by extension + EV->>RS: roomEvent(...) + RS->>RS: dispatch → Redux + end + + Note over PB,WS: No timeout, no retry.
resetCommands() on reconnect
silently drops pending callbacks. diff --git a/webclient/architecture/flow.png b/webclient/architecture/flow.png new file mode 100644 index 000000000..39a82cef7 Binary files /dev/null and b/webclient/architecture/flow.png differ diff --git a/webclient/architecture/simple.mmd b/webclient/architecture/simple.mmd new file mode 100644 index 000000000..19bba7d21 --- /dev/null +++ b/webclient/architecture/simple.mmd @@ -0,0 +1,58 @@ +--- +config: + theme: base + themeVariables: + background: "#ffffff" + primaryColor: "#ffffff" + primaryBorderColor: "#1f2937" + primaryTextColor: "#0b1220" + lineColor: "#1f2937" + textColor: "#0b1220" + edgeLabelBackground: "#ffffff" + fontSize: "18px" + clusterBkg: "#ffffff" + clusterBorder: "#9ca3af" + flowchart: + htmlLabels: true + curve: basis + nodeSpacing: 55 + rankSpacing: 90 +--- +flowchart LR + subgraph APP_COL[" "] + direction TB + App["Application
containers · components · hooks"] + Rdx[("Redux
in-memory state")] + end + + subgraph SRV_COL[" "] + direction TB + Srv[("Servatrice")] + IDB[("IndexedDB
local persistent store")] + end + + Req["client.request"] + Res["client.response"] + + %% Outbound lane (top) + App -- "useWebClient()" --> Req + Req -- "Commands" --> Srv + + %% Inbound lane (bottom) + Srv -- "Events · Responses" --> Res + Res -- "dispatch · rerender" --> App + + %% Local stores — Application owns both; edges only to IndexedDB + %% (Redux state is implicit — reducers sit under dispatch, selectors under rerender) + App -. "Dexie: settings · hosts · cards" .-> IDB + + %% Palette — four roles + classDef app fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef seam fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef store fill:#fde68a,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef external fill:#e5e7eb,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + + class App app + class Req,Res seam + class Rdx store + class Srv,IDB external diff --git a/webclient/architecture/simple.png b/webclient/architecture/simple.png new file mode 100644 index 000000000..0e040a2bb Binary files /dev/null and b/webclient/architecture/simple.png differ diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs index 05bb6cf83..d79272665 100644 --- a/webclient/eslint.boundaries.mjs +++ b/webclient/eslint.boundaries.mjs @@ -12,6 +12,7 @@ const elements = [ { type: 'services', pattern: ['src/services/**'] }, { type: 'store', pattern: ['src/store/**'] }, { type: 'types', pattern: ['src/types/**'] }, + { type: 'websocket-types', pattern: ['src/websocket/types/**'] }, { type: 'websocket', pattern: ['src/websocket/**'] }, ]; @@ -19,26 +20,27 @@ const types = (...types) => types.map((type) => ({ to: { type } })); const rules = [ { from: { type: 'generated' }, allow: [] }, - { from: { type: 'types' }, allow: types('generated', 'websocket') }, + { from: { type: 'websocket-types' }, allow: types('generated') }, + { from: { type: 'websocket' }, allow: types('generated', 'websocket-types') }, + { from: { type: 'types' }, allow: types('generated') }, - { from: { type: 'websocket' }, allow: types('generated') }, - { from: { type: 'store' }, allow: types('types') }, - { from: { type: 'api' }, allow: types('store', 'types', 'websocket') }, + { from: { type: 'store' }, allow: types('types', 'websocket-types') }, + { from: { type: 'api' }, allow: types('store', 'types', 'websocket', 'websocket-types') }, - { from: { type: 'hooks' }, allow: types('api', 'services', 'types', 'websocket') }, { from: { type: 'images' }, allow: types('types') }, { from: { type: 'services' }, allow: types('api', 'store', 'types') }, + { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket', 'websocket-types') }, { from: { type: 'components' }, - allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types') }, { from: { type: 'containers' }, - allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types') }, - { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types') }, - { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types') }, + { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'websocket-types') }, + { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'websocket-types') }, ]; export const boundariesConfig = [ diff --git a/webclient/integration/src/app/helpers.tsx b/webclient/integration/src/app/helpers.tsx new file mode 100644 index 000000000..ed4484e3a --- /dev/null +++ b/webclient/integration/src/app/helpers.tsx @@ -0,0 +1,33 @@ +// Shared render helper for the app integration suite. +// +// Two non-obvious choices: +// +// 1. WebClientContext is provided directly (not via production +// ) because the shared integration setup.ts already +// instantiates the WebClient singleton in beforeEach. The production +// provider would `new WebClient(...)` a second time and throw. +// +// 2. We pass the REAL Redux store from @app/store — not renderWithProviders' +// default test-local store. The real WebClient dispatches against the +// real store (that's what createWebClientResponse wires to). Asserting +// against a different in-memory store would silently miss every +// dispatch. setup.ts's resetAll + afterEach clears the real store +// between tests, so each test still starts from a clean slate. + +import { ReactElement } from 'react'; + +import { renderWithProviders } from '../../../src/__test-utils__'; +import { store } from '@app/store'; +import { WebClientContext } from '@app/hooks'; +import { WebClient } from '@app/websocket'; + +export function renderAppScreen(ui: ReactElement) { + return renderWithProviders( + + {ui} + , + { store } + ); +} + +export { store }; diff --git a/webclient/integration/src/app/login-autoconnect.spec.tsx b/webclient/integration/src/app/login-autoconnect.spec.tsx new file mode 100644 index 000000000..f68d1ef6d --- /dev/null +++ b/webclient/integration/src/app/login-autoconnect.spec.tsx @@ -0,0 +1,192 @@ +// Full-stack autoconnect integration. Only outbound surfaces are mocked +// (WebSocket via the shared setup, IndexedDB via fake-indexeddb in setup). +// Everything in between — Dexie, DTOs, useSettings/useKnownHosts, useAutoLogin, +// the Login container, WebClient, Redux — runs as shipped code. +// +// We assert auto-login via `connectionAttemptMade` on the real server slice, +// not via the WebSocket mock's call count: KnownHosts fires a testConnection +// on mount for the UX indicator, which also constructs sockets, so raw +// socket counts are noisy. Only the login path dispatches CONNECTION_ATTEMPTED. + +import { act, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Store loads notify React subscribers synchronously when the Dexie +// promise resolves. Awaiting whenReady() directly would let those +// notifications fire outside an act() scope, which trips React's +// "update was not wrapped in act" warning. Wrapping here captures +// both the store resolution and any resulting component re-renders. +const flushStoresAndEffects = async (): Promise => { + await act(async () => { + await settingsStore.whenReady(); + await knownHostsStore.whenReady(); + // Let dependent effects (host-sync, settings-sync) commit. + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +import { autoLoginGate } from '../../../src/hooks/useAutoLogin'; +import { settingsStore } from '../../../src/hooks/useSettings'; +import { knownHostsStore } from '../../../src/hooks/useKnownHosts'; +import Login from '../../../src/containers/Login/Login'; +import { HostDTO, SettingDTO } from '@app/services'; +import { App } from '@app/types'; +import { ServerSelectors, ServerDispatch } from '@app/store'; +import { WebsocketTypes } from '@app/websocket/types'; + +import { resetDexie } from '../services/dexie/resetDexie'; +import { renderAppScreen, store } from './helpers'; + +// Mimics the production "user logged out / connection dropped" transition: +// dispatching updateStatus(DISCONNECTED) is what the real reducer uses to +// clear connectionAttemptMade (clearStore intentionally preserves status). +const simulateLogout = () => { + ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null); +}; + +const seedAutoConnect = async () => { + const setting = new SettingDTO(App.APP_USER); + setting.autoConnect = true; + await setting.save(); + + const id = (await HostDTO.add({ + name: 'Test Server', + host: 'server.example', + port: '4748', + editable: false, + })) as number; + const host = (await HostDTO.get(id))!; + host.remember = true; + host.userName = 'alice'; + host.hashedPassword = 'stored-hash'; + host.lastSelected = true; + await host.save(); +}; + +const attempted = (): boolean => + ServerSelectors.getConnectionAttemptMade(store.getState()); + +afterEach(async () => { + // Absorb any state updates that lingered past the test body (stores + // resolving after unmount, trailing effect commits) so they're wrapped + // in act and don't trip React's warning during teardown. + await flushStoresAndEffects(); +}); + +beforeEach(async () => { + // setup.ts's beforeEach installs fake timers and re-creates the WebClient + // singleton. Dexie + React async need real timers; module caches persist + // across tests and need explicit reset. + vi.useRealTimers(); + await resetDexie(); + + // Reset the module-level caches that load from Dexie. Without this, a + // test would read the PREVIOUS test's snapshot (the Dexie clear only + // truncates storage, not the useSettings / useKnownHosts subscribers' + // cached values). + settingsStore.reset(); + knownHostsStore.reset(); + autoLoginGate.hasChecked = false; +}); + +describe('autoconnect — cold start', () => { + it('auto-logs in when Dexie has autoConnect=true + host with stored credentials', async () => { + await seedAutoConnect(); + + renderAppScreen(); + + await waitFor(() => { + expect(attempted()).toBe(true); + }); + }); + + it('does NOT attempt login when Dexie has no settings row', async () => { + renderAppScreen(); + + await flushStoresAndEffects(); + + expect(attempted()).toBe(false); + }); + + it('does NOT attempt login when autoConnect=true but lastSelected host lacks credentials', async () => { + const setting = new SettingDTO(App.APP_USER); + setting.autoConnect = true; + await setting.save(); + await HostDTO.add({ + name: 'Unremembered', + host: 'server.example', + port: '4748', + editable: false, + lastSelected: true, + }); + + renderAppScreen(); + + await flushStoresAndEffects(); + + expect(attempted()).toBe(false); + }); +}); + +describe('autoconnect — logout cycle (same session)', () => { + it('does not auto-reconnect after first auto-login + logout within the same JS session', async () => { + await seedAutoConnect(); + + const first = renderAppScreen(); + await waitFor(() => { + expect(attempted()).toBe(true); + }); + + // Simulate "logged out and returned to /login": unmount, clear the + // store's connectionAttemptMade flag (the app-level equivalent of + // DISCONNECTED → status.connectionAttemptMade reset), remount. + first.unmount(); + simulateLogout(); + + renderAppScreen(); + await flushStoresAndEffects(); + + // The session gate must have kept useAutoLogin silent; the flag stays + // false. + expect(attempted()).toBe(false); + }); + + it('does not auto-connect when the user enabled autoConnect mid-session and then logged out', async () => { + // First mount with autoConnect=false — gate latches without firing. + const first = renderAppScreen(); + await flushStoresAndEffects(); + expect(attempted()).toBe(false); + first.unmount(); + + // Mid-session: user ticked the checkbox → Dexie flipped to autoConnect=true. + await seedAutoConnect(); + + // Remount (post-logout). The gate MUST keep useAutoLogin silent. + renderAppScreen(); + await flushStoresAndEffects(); + + expect(attempted()).toBe(false); + }); +}); + +describe('autoconnect — refresh', () => { + it('auto-connects again after resetting the session gate (page-refresh equivalent)', async () => { + await seedAutoConnect(); + + const first = renderAppScreen(); + await waitFor(() => { + expect(attempted()).toBe(true); + }); + first.unmount(); + + // Simulate a browser refresh: the session gate naturally resets on a + // fresh JS context, and the real connection flag resets too. + simulateLogout(); + autoLoginGate.hasChecked = false; + + renderAppScreen(); + await waitFor(() => { + expect(attempted()).toBe(true); + }); + }); +}); diff --git a/webclient/integration/src/helpers/setup.ts b/webclient/integration/src/helpers/setup.ts index 8e4413d98..e9bfc56de 100644 --- a/webclient/integration/src/helpers/setup.ts +++ b/webclient/integration/src/helpers/setup.ts @@ -9,19 +9,18 @@ import '@testing-library/jest-dom/vitest'; import '../../../src/polyfills'; +// fake-indexeddb polyfills globalThis.indexedDB. MUST be imported before any +// module that opens a Dexie database (Dexie opens on first table access). +// Harmless for the websocket suite, which doesn't touch Dexie. +import 'fake-indexeddb/auto'; import { create } from '@bufbuild/protobuf'; import { afterEach, beforeEach, vi } from 'vitest'; import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store'; import { Data } from '@app/types'; -import { - WebClient, - StatusEnum, - WebSocketConnectReason, - setPendingOptions, -} from '@app/websocket'; -import type { WebSocketConnectOptions } from '@app/websocket'; +import { WebClient, setPendingOptions } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { PROTOCOL_VERSION } from '../../../src/websocket/config'; import { createWebClientRequest, createWebClientResponse } from '@app/api'; @@ -105,7 +104,7 @@ function resetAll(): void { } client.protobuf.resetCommands(); - client.status = StatusEnum.DISCONNECTED; + client.status = WebsocketTypes.StatusEnum.DISCONNECTED; ServerDispatch.clearStore(); RoomsDispatch.clearStore(); @@ -124,8 +123,8 @@ function resetAll(): void { // ── Shared connect helpers ────────────────────────────────────────────────── -const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = { - reason: WebSocketConnectReason.LOGIN, +const DEFAULT_LOGIN_OPTIONS: WebsocketTypes.WebSocketConnectOptions = { + reason: WebsocketTypes.WebSocketConnectReason.LOGIN, host: 'localhost', port: '4748', userName: 'alice', @@ -133,16 +132,16 @@ const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = { }; export function connectRaw( - overrides: Partial = {} + overrides: Partial = {} ): void { const opts = { ...DEFAULT_LOGIN_OPTIONS, ...overrides }; - setPendingOptions(opts as WebSocketConnectOptions); + setPendingOptions(opts as WebsocketTypes.WebSocketConnectOptions); getWebClient().connect({ host: opts.host, port: opts.port }); openMockWebSocket(); } export function connectAndHandshake( - overrides: Partial = {} + overrides: Partial = {} ): void { connectRaw(overrides); deliverMessage(buildSessionEventMessage( @@ -156,7 +155,7 @@ export function connectAndHandshake( } export function connectAndHandshakeWithSalt( - overrides: Partial = {} + overrides: Partial = {} ): void { connectRaw(overrides); deliverMessage(buildSessionEventMessage( diff --git a/webclient/integration/src/services/dexie/hosts.spec.ts b/webclient/integration/src/services/dexie/hosts.spec.ts new file mode 100644 index 000000000..ffedeb73b --- /dev/null +++ b/webclient/integration/src/services/dexie/hosts.spec.ts @@ -0,0 +1,91 @@ +// Real round-trip tests for HostDTO through Dexie into fake-indexeddb. +// Exercises the full static method surface (add, get, getAll, bulkAdd, +// delete) plus instance save(). + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HostDTO } from '@app/services'; +import type { App } from '@app/types'; + +import { resetDexie } from './resetDexie'; + +const makeRow = (overrides: Partial = {}): App.Host => ({ + name: 'Test', + host: 'host.example', + port: '4747', + editable: false, + ...overrides, +}); + +beforeEach(async () => { + // Shared setup.ts installs fake timers for the websocket suite's + // KeepAliveService; Dexie / fake-indexeddb need real timers. + vi.useRealTimers(); + await resetDexie(); +}); + +describe('HostDTO (real Dexie)', () => { + it('getAll returns empty on a fresh store', async () => { + const all = await HostDTO.getAll(); + expect(all).toEqual([]); + }); + + it('add returns an auto-incremented id and makes the row retrievable by get(id)', async () => { + const id = (await HostDTO.add(makeRow({ name: 'A' }))) as number; + expect(typeof id).toBe('number'); + + const loaded = await HostDTO.get(id); + expect(loaded).toBeDefined(); + expect(loaded!.name).toBe('A'); + expect(loaded!.id).toBe(id); + expect(loaded).toBeInstanceOf(HostDTO); + }); + + it('bulkAdd seeds multiple rows and they are all retrievable via getAll', async () => { + await HostDTO.bulkAdd([ + makeRow({ name: 'A' }), + makeRow({ name: 'B' }), + makeRow({ name: 'C' }), + ]); + + const all = await HostDTO.getAll(); + expect(all.map((h) => h.name).sort()).toEqual(['A', 'B', 'C']); + }); + + it('save() on a loaded instance upserts the same row (does not duplicate)', async () => { + const id = (await HostDTO.add(makeRow({ name: 'A', remember: false }))) as number; + + const loaded = await HostDTO.get(id); + loaded!.remember = true; + loaded!.userName = 'alice'; + loaded!.hashedPassword = 'stored'; + await loaded!.save(); + + const all = await HostDTO.getAll(); + expect(all).toHaveLength(1); + expect(all[0].remember).toBe(true); + expect(all[0].userName).toBe('alice'); + expect(all[0].hashedPassword).toBe('stored'); + }); + + it('delete removes the row by id', async () => { + const idA = (await HostDTO.add(makeRow({ name: 'A' }))) as number; + await HostDTO.add(makeRow({ name: 'B' })); + + await HostDTO.delete(idA as unknown as string); + + const all = await HostDTO.getAll(); + expect(all.map((h) => h.name)).toEqual(['B']); + }); + + it('lastSelected round-trips as a boolean column', async () => { + const idA = (await HostDTO.add(makeRow({ name: 'A', lastSelected: true }))) as number; + await HostDTO.add(makeRow({ name: 'B', lastSelected: false })); + + const all = await HostDTO.getAll(); + const selected = all.find((h) => h.id === idA)!; + expect(selected.lastSelected).toBe(true); + const other = all.find((h) => h.name === 'B')!; + expect(other.lastSelected).toBe(false); + }); +}); diff --git a/webclient/integration/src/services/dexie/resetDexie.ts b/webclient/integration/src/services/dexie/resetDexie.ts new file mode 100644 index 000000000..52ef321b1 --- /dev/null +++ b/webclient/integration/src/services/dexie/resetDexie.ts @@ -0,0 +1,12 @@ +// Clears every table the services suite touches so each test starts from +// empty storage. Dexie is a real singleton, the database a real (fake- +// indexeddb) instance, so state leaks between tests otherwise. + +import { dexieService } from '@app/services'; + +export async function resetDexie(): Promise { + await Promise.all([ + dexieService.settings.clear(), + dexieService.hosts.clear(), + ]); +} diff --git a/webclient/integration/src/services/dexie/settings.spec.ts b/webclient/integration/src/services/dexie/settings.spec.ts new file mode 100644 index 000000000..a3744063f --- /dev/null +++ b/webclient/integration/src/services/dexie/settings.spec.ts @@ -0,0 +1,69 @@ +// Real round-trip tests for SettingDTO through Dexie into fake-indexeddb. +// Nothing is mocked past the IndexedDB boundary — the DTO class, the Dexie +// schema, and the table's put/where/first pipeline all run as shipped code. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SettingDTO } from '@app/services'; +import { App } from '@app/types'; + +import { resetDexie } from './resetDexie'; + +beforeEach(async () => { + // Shared setup.ts installs vi.useFakeTimers() for the websocket suite's + // KeepAliveService needs. Dexie + fake-indexeddb rely on real microtasks + // and will hang under fake timers, so flip back here. + vi.useRealTimers(); + await resetDexie(); +}); + +describe('SettingDTO (real Dexie)', () => { + it('returns undefined for a user with no row yet', async () => { + const loaded = await SettingDTO.get(App.APP_USER); + expect(loaded).toBeUndefined(); + }); + + it('round-trips a fresh setting via save()', async () => { + const dto = new SettingDTO(App.APP_USER); + dto.autoConnect = true; + await dto.save(); + + const loaded = await SettingDTO.get(App.APP_USER); + expect(loaded).toBeDefined(); + expect(loaded!.user).toBe(App.APP_USER); + expect(loaded!.autoConnect).toBe(true); + }); + + it('upserts on repeated save for the same user key', async () => { + const first = new SettingDTO(App.APP_USER); + first.autoConnect = false; + await first.save(); + + const loaded = await SettingDTO.get(App.APP_USER); + loaded!.autoConnect = true; + await loaded!.save(); + + const reloaded = await SettingDTO.get(App.APP_USER); + expect(reloaded!.autoConnect).toBe(true); + }); + + it('matches user lookups case-insensitively (equalsIgnoreCase in DTO.get)', async () => { + const dto = new SettingDTO(App.APP_USER); + await dto.save(); + + const loaded = await SettingDTO.get(App.APP_USER.toUpperCase()); + expect(loaded).toBeDefined(); + expect(loaded!.user).toBe(App.APP_USER); + }); + + it('preserves the SettingDTO class on load (mapToClass binding)', async () => { + const dto = new SettingDTO(App.APP_USER); + await dto.save(); + + const loaded = await SettingDTO.get(App.APP_USER); + expect(loaded).toBeInstanceOf(SettingDTO); + // The save() instance method must be present on the retrieved row so + // call sites (useSettings.update) can round-trip without reinstantiation. + expect(typeof loaded!.save).toBe('function'); + }); +}); diff --git a/webclient/integration/src/admin.spec.ts b/webclient/integration/src/websocket/admin.spec.ts similarity index 92% rename from webclient/integration/src/admin.spec.ts rename to webclient/integration/src/websocket/admin.spec.ts index f67bcfc6f..2c2e02ff1 100644 --- a/webclient/integration/src/admin.spec.ts +++ b/webclient/integration/src/websocket/admin.spec.ts @@ -8,14 +8,14 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { AdminCommands } from '@app/websocket'; -import { connectAndLogin } from './helpers/setup'; +import { connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastAdminCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastAdminCommand } from '../helpers/command-capture'; describe('admin commands', () => { it('adjustMod modifies the user level bitflags on success', () => { diff --git a/webclient/integration/src/authentication.spec.ts b/webclient/integration/src/websocket/authentication.spec.ts similarity index 89% rename from webclient/integration/src/authentication.spec.ts rename to webclient/integration/src/websocket/authentication.spec.ts index c5149c809..c2fc04428 100644 --- a/webclient/integration/src/authentication.spec.ts +++ b/webclient/integration/src/websocket/authentication.spec.ts @@ -6,15 +6,15 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -import { connectAndHandshake, connectAndHandshakeWithSalt } from './helpers/setup'; +import { connectAndHandshake, connectAndHandshakeWithSalt } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; function makeUser(name: string): Data.ServerInfo_User { return create(Data.ServerInfo_UserSchema, { @@ -44,7 +44,7 @@ describe('authentication', () => { }))); const state = store.getState().server; - expect(state.status.state).toBe(StatusEnum.LOGGED_IN); + expect(state.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); expect(state.status.description).toBe('Logged in.'); expect(state.user?.name).toBe('alice'); expect(Object.keys(state.buddyList)).toEqual(['bob']); @@ -64,7 +64,7 @@ describe('authentication', () => { }))); const state = store.getState().server; - expect(state.status.state).toBe(StatusEnum.DISCONNECTED); + expect(state.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(state.user).toBeNull(); expect(state.buddyList).toEqual({}); }); @@ -72,7 +72,7 @@ describe('authentication', () => { describe('register', () => { const registerOptions = { - reason: WebSocketConnectReason.REGISTER as const, + reason: WebsocketTypes.WebSocketConnectReason.REGISTER as const, host: 'localhost', port: '4748', userName: 'newbie', @@ -107,7 +107,7 @@ describe('authentication', () => { responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation, }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); }); }); @@ -115,7 +115,7 @@ describe('authentication', () => { describe('activate', () => { it('auto-logs-in on RespActivationAccepted', () => { connectAndHandshake({ - reason: WebSocketConnectReason.ACTIVATE_ACCOUNT as const, + reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT as const, host: 'localhost', port: '4748', userName: 'alice', @@ -171,7 +171,7 @@ describe('authentication', () => { }), }))); - expect(store.getState().server.status.state).toBe(StatusEnum.LOGGED_IN); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); }); }); }); diff --git a/webclient/integration/src/connection.spec.ts b/webclient/integration/src/websocket/connection.spec.ts similarity index 77% rename from webclient/integration/src/connection.spec.ts rename to webclient/integration/src/websocket/connection.spec.ts index 31cd41b83..1903bb75d 100644 --- a/webclient/integration/src/connection.spec.ts +++ b/webclient/integration/src/websocket/connection.spec.ts @@ -7,9 +7,9 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -import { PROTOCOL_VERSION } from '../../src/websocket/config'; +import { PROTOCOL_VERSION } from '../../../src/websocket/config'; import { getMockWebSocket, @@ -17,18 +17,16 @@ import { openMockWebSocket, setPendingOptions, connectAndHandshake, -} from './helpers/setup'; -import type { WebSocketConnectOptions } from '@app/websocket'; -import { WebSocketConnectReason } from '@app/websocket'; +} from '../helpers/setup'; import { buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; -function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions { +function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebsocketTypes.WebSocketConnectOptions { return { - reason: WebSocketConnectReason.LOGIN, + reason: WebsocketTypes.WebSocketConnectReason.LOGIN, host: 'localhost', port: '4748', userName: overrides.userName ?? 'alice', @@ -36,7 +34,7 @@ function loginOptions(overrides: Partial<{ userName: string; password: string }> }; } -function connectWithOptions(opts: WebSocketConnectOptions): void { +function connectWithOptions(opts: WebsocketTypes.WebSocketConnectOptions): void { setPendingOptions(opts); getWebClient().connect({ host: opts.host, port: opts.port }); } @@ -63,7 +61,7 @@ describe('connection lifecycle', () => { openMockWebSocket(); - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); expect(store.getState().server.status.description).toBe('Connected'); }); @@ -73,7 +71,7 @@ describe('connection lifecycle', () => { deliverMessage(serverIdentification()); - expect(store.getState().server.status.state).toBe(StatusEnum.LOGGING_IN); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.LOGGING_IN); expect(store.getState().server.info.name).toBe('TestServer'); expect(store.getState().server.info.version).toBe('2.8.0'); @@ -90,7 +88,7 @@ describe('connection lifecycle', () => { const mock = getMockWebSocket(); expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); }); @@ -103,7 +101,7 @@ describe('connection lifecycle', () => { vi.advanceTimersByTime(5000); expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('releases keep-alive ping loop on explicit disconnect', () => { @@ -115,7 +113,7 @@ describe('connection lifecycle', () => { getWebClient().disconnect(); expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('drops pending commands and clears state on unexpected socket close', () => { @@ -129,6 +127,6 @@ describe('connection lifecycle', () => { mock.readyState = 3; mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); \ No newline at end of file diff --git a/webclient/integration/src/deck.spec.ts b/webclient/integration/src/websocket/deck.spec.ts similarity index 95% rename from webclient/integration/src/deck.spec.ts rename to webclient/integration/src/websocket/deck.spec.ts index 76ed032be..a7a8da0eb 100644 --- a/webclient/integration/src/deck.spec.ts +++ b/webclient/integration/src/websocket/deck.spec.ts @@ -8,13 +8,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { SessionCommands } from '@app/websocket'; -import { connectAndLogin } from './helpers/setup'; +import { connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; describe('deck operations', () => { it('populates backendDecks from deckList response', () => { diff --git a/webclient/integration/src/game.spec.ts b/webclient/integration/src/websocket/game.spec.ts similarity index 98% rename from webclient/integration/src/game.spec.ts rename to webclient/integration/src/websocket/game.spec.ts index eb88e6b2b..4775b5332 100644 --- a/webclient/integration/src/game.spec.ts +++ b/webclient/integration/src/websocket/game.spec.ts @@ -8,7 +8,7 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { GameCommands, RoomCommands } from '@app/websocket'; -import { connectAndHandshake, connectAndLogin } from './helpers/setup'; +import { connectAndHandshake, connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, @@ -16,8 +16,8 @@ import { buildRoomEventMessage, buildGameEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from '../helpers/command-capture'; function joinGame(gameId: number): void { deliverMessage(buildSessionEventMessage( diff --git a/webclient/integration/src/keep-alive.spec.ts b/webclient/integration/src/websocket/keep-alive.spec.ts similarity index 73% rename from webclient/integration/src/keep-alive.spec.ts rename to webclient/integration/src/websocket/keep-alive.spec.ts index c4889e0fc..0f6d6f4b5 100644 --- a/webclient/integration/src/keep-alive.spec.ts +++ b/webclient/integration/src/websocket/keep-alive.spec.ts @@ -4,15 +4,15 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -import { connectRaw, getMockWebSocket } from './helpers/setup'; +import { connectRaw, getMockWebSocket } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; describe('keep-alive', () => { it('sends a Command_Ping on every keepalive interval tick', () => { @@ -32,7 +32,7 @@ describe('keep-alive', () => { vi.advanceTimersByTime(5000); const second = findLastSessionCommand(Data.Command_Ping_ext); expect(second.cmdId).toBeGreaterThan(first.cmdId); - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); }); it('stays CONNECTED while pongs arrive before the next tick', () => { @@ -47,7 +47,7 @@ describe('keep-alive', () => { }))); } - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); expect(getMockWebSocket().close).not.toHaveBeenCalled(); }); @@ -56,11 +56,11 @@ describe('keep-alive', () => { vi.advanceTimersByTime(5000); expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow(); - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); vi.advanceTimersByTime(5000); expect(getMockWebSocket().close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); diff --git a/webclient/integration/src/moderator.spec.ts b/webclient/integration/src/websocket/moderator.spec.ts similarity index 95% rename from webclient/integration/src/moderator.spec.ts rename to webclient/integration/src/websocket/moderator.spec.ts index c90c3b026..088d254dc 100644 --- a/webclient/integration/src/moderator.spec.ts +++ b/webclient/integration/src/websocket/moderator.spec.ts @@ -9,13 +9,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { ModeratorCommands } from '@app/websocket'; -import { connectAndLogin } from './helpers/setup'; +import { connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastModeratorCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastModeratorCommand } from '../helpers/command-capture'; describe('moderator commands', () => { it('getBanHistory populates server.banHistory on success', () => { diff --git a/webclient/integration/src/password-reset.spec.ts b/webclient/integration/src/websocket/password-reset.spec.ts similarity index 75% rename from webclient/integration/src/password-reset.spec.ts rename to webclient/integration/src/websocket/password-reset.spec.ts index ec842c3ec..f4aa48901 100644 --- a/webclient/integration/src/password-reset.spec.ts +++ b/webclient/integration/src/websocket/password-reset.spec.ts @@ -6,20 +6,20 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -import { connectAndHandshake } from './helpers/setup'; +import { connectAndHandshake } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; describe('password reset', () => { it('forgotPasswordRequest sends command and disconnects on success', () => { connectAndHandshake({ - reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST as const, + reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST as const, host: 'localhost', port: '4748', userName: 'alice', @@ -37,12 +37,12 @@ describe('password reset', () => { }), }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('forgotPasswordChallenge sends command with userName and email', () => { connectAndHandshake({ - reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const, + reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const, host: 'localhost', port: '4748', userName: 'alice', @@ -58,12 +58,12 @@ describe('password reset', () => { responseCode: Data.Response_ResponseCode.RespOk, }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('forgotPasswordReset sends command with userName, token, and newPassword', () => { connectAndHandshake({ - reason: WebSocketConnectReason.PASSWORD_RESET as const, + reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET as const, host: 'localhost', port: '4748', userName: 'alice', @@ -81,6 +81,6 @@ describe('password reset', () => { responseCode: Data.Response_ResponseCode.RespOk, }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); \ No newline at end of file diff --git a/webclient/integration/src/rooms.spec.ts b/webclient/integration/src/websocket/rooms.spec.ts similarity index 98% rename from webclient/integration/src/rooms.spec.ts rename to webclient/integration/src/websocket/rooms.spec.ts index 5bd776d08..77236d8c7 100644 --- a/webclient/integration/src/rooms.spec.ts +++ b/webclient/integration/src/websocket/rooms.spec.ts @@ -8,15 +8,15 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { RoomCommands } from '@app/websocket'; -import { connectAndHandshake } from './helpers/setup'; +import { connectAndHandshake } from '../helpers/setup'; import { buildResponse, buildResponseMessage, buildRoomEventMessage, buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from '../helpers/command-capture'; import { fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf'; function makeRoom(overrides: Partial<{ diff --git a/webclient/integration/src/server-events.spec.ts b/webclient/integration/src/websocket/server-events.spec.ts similarity index 93% rename from webclient/integration/src/server-events.spec.ts rename to webclient/integration/src/websocket/server-events.spec.ts index 16ac3a6bf..0a27a9669 100644 --- a/webclient/integration/src/server-events.spec.ts +++ b/webclient/integration/src/websocket/server-events.spec.ts @@ -6,13 +6,13 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -import { connectAndHandshake } from './helpers/setup'; +import { connectAndHandshake } from '../helpers/setup'; import { buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; +} from '../helpers/protobuf-builders'; describe('server events', () => { it('writes the server banner into server.info.message on Event_ServerMessage', () => { @@ -73,7 +73,7 @@ describe('server events', () => { )); const status = store.getState().server.status; - expect(status.state).toBe(StatusEnum.DISCONNECTED); + expect(status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(status.description).toBe('kicked by admin'); }); diff --git a/webclient/integration/src/users.spec.ts b/webclient/integration/src/websocket/users.spec.ts similarity index 95% rename from webclient/integration/src/users.spec.ts rename to webclient/integration/src/websocket/users.spec.ts index 36062963d..e1cc5777a 100644 --- a/webclient/integration/src/users.spec.ts +++ b/webclient/integration/src/websocket/users.spec.ts @@ -6,14 +6,14 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { connectAndLogin } from './helpers/setup'; +import { connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; function makeUser(name: string): Data.ServerInfo_User { return create(Data.ServerInfo_UserSchema, { diff --git a/webclient/package-lock.json b/webclient/package-lock.json index ed7f1854a..8697b0d3e 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -57,6 +57,7 @@ "eslint": "^10.2.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-boundaries": "^6.0.2", + "fake-indexeddb": "^6.2.5", "fs-extra": "^11.3.4", "globals": "^17.5.0", "husky": "^9.1.7", @@ -3372,6 +3373,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/webclient/package.json b/webclient/package.json index 774cef831..386070a37 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -19,7 +19,11 @@ "golden:coverage": "npm run lint && npm run test:coverage && npm run test:integration:coverage", "prepare": "cd .. && husky", "translate": "node prebuild.js -i18nOnly", - "proto:generate": "npx buf generate" + "proto:generate": "npx buf generate", + "diagram": "npm run diagram:simple && npm run diagram:detailed && npm run diagram:flow", + "diagram:simple": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/simple.mmd -o architecture/simple.png -b white -s 2", + "diagram:detailed": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/detailed.mmd -o architecture/detailed.png -b white -s 2", + "diagram:flow": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/flow.mmd -o architecture/flow.png -b white -s 2" }, "dependencies": { "@bufbuild/protobuf": "^2.11.0", @@ -71,6 +75,7 @@ "eslint": "^10.2.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-boundaries": "^6.0.2", + "fake-indexeddb": "^6.2.5", "fs-extra": "^11.3.4", "globals": "^17.5.0", "husky": "^9.1.7", diff --git a/webclient/src/__test-utils__/globalGuards.ts b/webclient/src/__test-utils__/globalGuards.ts index b350d440f..ee39ee223 100644 --- a/webclient/src/__test-utils__/globalGuards.ts +++ b/webclient/src/__test-utils__/globalGuards.ts @@ -1,15 +1,9 @@ -// Shared lifecycle helpers for test files that need to mutate global state. -// -// The root `setupTests.ts` guards catch leaks even when callers forget to -// clean up, but opt-in helpers make intent explicit at the call site and -// avoid piling cleanup logic onto the shared safety net. - /** * Temporarily override fields on `window.location` and return a restore fn. * - * `Object.defineProperty(window, 'location', ...)` is not a `vi.spyOn` target, - * so `vi.restoreAllMocks()` will NOT undo it. Always pair with the returned - * `restore` callback (ideally in `afterEach`). + * @critical `Object.defineProperty(window, 'location', ...)` isn't a vi.spyOn + * target, so `vi.restoreAllMocks()` will NOT undo it. Always invoke the + * returned restore callback. */ export function withMockLocation(overrides: Partial): () => void { const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); @@ -26,23 +20,3 @@ export function withMockLocation(overrides: Partial): () => void { } }; } - -/** - * Push an entry onto a shared event-handler registry array and return a - * teardown function that removes exactly that entry. - * - * Used by ProtobufService specs which install temporary handlers into the - * (mocked) `GameEvents` / `RoomEvents` / `SessionEvents` arrays. Manual - * `.push()`/`.pop()` inside a test body corrupts the array if an assertion - * throws between them — this helper makes the teardown safe to run in - * `afterEach`. - */ -export function withEventRegistry(registry: T[], entry: T): () => void { - registry.push(entry); - return () => { - const index = registry.lastIndexOf(entry); - if (index !== -1) { - registry.splice(index, 1); - } - }; -} diff --git a/webclient/src/__test-utils__/index.ts b/webclient/src/__test-utils__/index.ts index 6d606210a..7ee47e601 100644 --- a/webclient/src/__test-utils__/index.ts +++ b/webclient/src/__test-utils__/index.ts @@ -1 +1,4 @@ -export { withMockLocation, withEventRegistry } from './globalGuards'; +export { withMockLocation } from './globalGuards'; +export { renderWithProviders } from './renderWithProviders'; +export { createMockWebClient } from './mockWebClient'; +export { disconnectedState, connectedState, connectedWithRoomsState, makeUser } from './storeFixtures'; diff --git a/webclient/src/__test-utils__/mockWebClient.ts b/webclient/src/__test-utils__/mockWebClient.ts new file mode 100644 index 000000000..875a4a9d9 --- /dev/null +++ b/webclient/src/__test-utils__/mockWebClient.ts @@ -0,0 +1,57 @@ +import type { WebClient } from '@app/websocket'; + +/** + * Creates a mock WebClient whose `request` property has vi.fn() stubs + * for every service method that containers/forms call. Inject via a + * vi.hoisted reference returned from a `vi.mock('@app/hooks', ...)` stub + * of `useWebClient`; see LoginForm.spec.tsx for the canonical pattern. + */ +export function createMockWebClient() { + return { + request: { + authentication: { + login: vi.fn(), + register: vi.fn(), + disconnect: vi.fn(), + activateAccount: vi.fn(), + resetPasswordRequest: vi.fn(), + resetPasswordChallenge: vi.fn(), + resetPassword: vi.fn(), + }, + session: { + addToBuddyList: vi.fn(), + removeFromBuddyList: vi.fn(), + addToIgnoreList: vi.fn(), + removeFromIgnoreList: vi.fn(), + getUserInfo: vi.fn(), + accountEdit: vi.fn(), + accountPassword: vi.fn(), + accountImage: vi.fn(), + listUsers: vi.fn(), + }, + rooms: { + joinRoom: vi.fn(), + leaveRoom: vi.fn(), + roomSay: vi.fn(), + createGame: vi.fn(), + }, + game: { + joinGame: vi.fn(), + leaveGame: vi.fn(), + }, + admin: { + adjustMod: vi.fn(), + reloadConfig: vi.fn(), + shutdownServer: vi.fn(), + updateServerMessage: vi.fn(), + }, + moderator: { + viewLogHistory: vi.fn(), + banFromServer: vi.fn(), + warnUser: vi.fn(), + warnHistory: vi.fn(), + banHistory: vi.fn(), + }, + }, + } as unknown as WebClient; +} diff --git a/webclient/src/__test-utils__/renderWithProviders.tsx b/webclient/src/__test-utils__/renderWithProviders.tsx new file mode 100644 index 000000000..7d78f171b --- /dev/null +++ b/webclient/src/__test-utils__/renderWithProviders.tsx @@ -0,0 +1,72 @@ +import { ReactElement } from 'react'; +import { render, RenderOptions } from '@testing-library/react'; +import { configureStore, EnhancedStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import { gamesReducer } from '../store/game'; +import { roomsReducer } from '../store/rooms'; +import { serverReducer } from '../store/server'; +import { actionReducer } from '../store/actions'; +import { ToastProvider } from '../components/Toast/ToastContext'; +import type { RootState } from '../store/store'; + +// Non-empty `resources` registers en-US so `resolvedLanguage` is defined; +// without it MUI warns about out-of-range Select values. +const testI18n = i18n.createInstance(); +testI18n.use(initReactI18next).init({ + lng: 'en-US', + resources: { 'en-US': { translation: {} } }, + fallbackLng: 'en-US', + interpolation: { escapeValue: false }, +}); + +function createTestStore(preloadedState?: Partial) { + return configureStore({ + reducer: { + games: gamesReducer, + rooms: roomsReducer, + server: serverReducer, + action: actionReducer, + }, + preloadedState: preloadedState as any, + }); +} + +interface ExtendedRenderOptions extends Omit { + preloadedState?: Partial; + store?: EnhancedStore; + route?: string; +} + +export function renderWithProviders( + ui: ReactElement, + { + preloadedState, + store = createTestStore(preloadedState), + route = '/', + ...renderOptions + }: ExtendedRenderOptions = {}, +) { + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + + {children} + + + + + ); + } + + return { + store, + ...render(ui, { wrapper: Wrapper, ...renderOptions }), + }; +} diff --git a/webclient/src/__test-utils__/storeFixtures.ts b/webclient/src/__test-utils__/storeFixtures.ts new file mode 100644 index 000000000..2d7a57878 --- /dev/null +++ b/webclient/src/__test-utils__/storeFixtures.ts @@ -0,0 +1,124 @@ +import { App, Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; +import type { RootState } from '../store/store'; + +/** + * Create a minimal ServerInfo_User object for testing. + */ +function makeUser(overrides: Partial = {}): Data.ServerInfo_User { + return { + name: 'testUser', + realName: '', + country: 'us', + userLevel: 0, + avatarBmp: new Uint8Array(), + accountageSecs: BigInt(0), + $typeName: 'ServerInfo_User' as any, + $unknown: undefined, + gender: 0, + ...overrides, + } as Data.ServerInfo_User; +} + +/** + * A disconnected (default) store state. This is the state before any + * connection to a server has been made. + */ +export const disconnectedState: Partial = { + server: { + initialized: false, + buddyList: {}, + ignoreList: {}, + status: { + connectionAttemptMade: false, + state: WebsocketTypes.StatusEnum.DISCONNECTED, + description: null, + }, + info: { message: null, name: null, version: null }, + logs: { room: [], game: [], chat: [] }, + user: null, + users: {}, + sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC }, + messages: {}, + userInfo: {}, + notifications: [], + serverShutdown: null, + banUser: '', + banHistory: {}, + warnHistory: {}, + warnListOptions: [], + warnUser: '', + adminNotes: {}, + replays: {}, + backendDecks: null, + downloadedDeck: null, + downloadedReplay: null, + gamesOfUser: {}, + registrationError: null, + }, + rooms: { + rooms: {}, + joinedRoomIds: {}, + joinedGameIds: {}, + messages: {}, + sortGamesBy: { field: App.GameSortField.START_TIME, order: App.SortDirection.DESC }, + sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC }, + }, + games: { games: {} }, + action: { type: null, payload: null, meta: null, error: false, count: 0 }, +}; + +/** + * A connected (logged-in) store state with a basic user and server info. + */ +export const connectedState: Partial = { + ...disconnectedState, + server: { + ...(disconnectedState.server as any), + initialized: true, + status: { + connectionAttemptMade: true, + state: WebsocketTypes.StatusEnum.LOGGED_IN, + description: null, + }, + info: { + message: 'Welcome', + name: 'Test Server', + version: '1.0.0', + }, + user: makeUser(), + users: { + testUser: makeUser(), + }, + }, +}; + +/** + * Connected state with rooms and a joined room containing games and users. + */ +export const connectedWithRoomsState: Partial = { + ...connectedState, + server: { + ...(connectedState.server as any), + users: { + testUser: makeUser(), + otherUser: makeUser({ name: 'otherUser' }), + }, + }, + rooms: { + ...(disconnectedState.rooms as any), + rooms: { + 1: { + info: { roomId: 1, name: 'Main Room', description: 'The main room', autoJoin: true, permissionLevel: 0 }, + gameList: [], + userList: [makeUser(), makeUser({ name: 'otherUser' })], + }, + }, + joinedRoomIds: { 1: true }, + messages: { + 1: [], + }, + }, +}; + +export { makeUser }; diff --git a/webclient/src/api/request/AdminRequestImpl.ts b/webclient/src/api/request/AdminRequestImpl.ts index 5cc21695b..1c22d7be6 100644 --- a/webclient/src/api/request/AdminRequestImpl.ts +++ b/webclient/src/api/request/AdminRequestImpl.ts @@ -1,7 +1,7 @@ -import type { IAdminRequest } from '@app/websocket'; import { AdminCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class AdminRequestImpl implements IAdminRequest { +export class AdminRequestImpl implements WebsocketTypes.IAdminRequest { adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void { AdminCommands.adjustMod(userName, shouldBeMod, shouldBeJudge); } diff --git a/webclient/src/api/request/AuthenticationRequestImpl.ts b/webclient/src/api/request/AuthenticationRequestImpl.ts index 91788d862..ece8a1891 100644 --- a/webclient/src/api/request/AuthenticationRequestImpl.ts +++ b/webclient/src/api/request/AuthenticationRequestImpl.ts @@ -1,70 +1,58 @@ import { WebClient, - StatusEnum, SessionCommands, - WebSocketConnectReason, setPendingOptions, } from '@app/websocket'; -import type { - IAuthenticationRequest, - AuthRequestMap, - LoginConnectOptions, - TestConnectionOptions, - RegisterConnectOptions, - ActivateConnectOptions, - PasswordResetRequestConnectOptions, - PasswordResetChallengeConnectOptions, - PasswordResetConnectOptions, -} from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -interface AppAuthRequestOverrides extends AuthRequestMap { - LoginParams: Omit; - ConnectTarget: Omit; - RegisterParams: Omit; - ActivateParams: Omit; - ForgotPasswordRequestParams: Omit; - ForgotPasswordChallengeParams: Omit; - ForgotPasswordResetParams: Omit; +interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap { + LoginParams: Omit; + ConnectTarget: Omit; + RegisterParams: Omit; + ActivateParams: Omit; + ForgotPasswordRequestParams: Omit; + ForgotPasswordChallengeParams: Omit; + ForgotPasswordResetParams: Omit; } -export class AuthenticationRequestImpl implements IAuthenticationRequest { - login(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.LOGIN }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); +export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest { + login(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.LOGIN }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - testConnection(options: Omit): void { + testConnection(options: Omit): void { WebClient.instance.testConnect({ host: options.host, port: options.port }); } - register(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.REGISTER }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + register(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.REGISTER }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - activateAccount(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + activateAccount(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordRequest(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + resetPasswordRequest(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordChallenge(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + resetPasswordChallenge(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPassword(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + resetPassword(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } diff --git a/webclient/src/api/request/GameRequestImpl.ts b/webclient/src/api/request/GameRequestImpl.ts index e594118cc..78a9dc829 100644 --- a/webclient/src/api/request/GameRequestImpl.ts +++ b/webclient/src/api/request/GameRequestImpl.ts @@ -1,9 +1,9 @@ -import type { IGameRequest } from '@app/websocket'; import { GameCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { Data } from '@app/types'; -export class GameRequestImpl implements IGameRequest { +export class GameRequestImpl implements WebsocketTypes.IGameRequest { leaveGame(gameId: number): void { GameCommands.leaveGame(gameId); } diff --git a/webclient/src/api/request/ModeratorRequestImpl.ts b/webclient/src/api/request/ModeratorRequestImpl.ts index 9f5c063da..97984e397 100644 --- a/webclient/src/api/request/ModeratorRequestImpl.ts +++ b/webclient/src/api/request/ModeratorRequestImpl.ts @@ -1,8 +1,8 @@ import { Data } from '@app/types'; -import type { IModeratorRequest } from '@app/websocket'; import { ModeratorCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class ModeratorRequestImpl implements IModeratorRequest { +export class ModeratorRequestImpl implements WebsocketTypes.IModeratorRequest { banFromServer( minutes: number, userName?: string, diff --git a/webclient/src/api/request/RoomsRequestImpl.ts b/webclient/src/api/request/RoomsRequestImpl.ts index 62b1f4e9b..e7264ca3b 100644 --- a/webclient/src/api/request/RoomsRequestImpl.ts +++ b/webclient/src/api/request/RoomsRequestImpl.ts @@ -1,7 +1,7 @@ -import type { IRoomsRequest } from '@app/websocket'; import { RoomCommands, SessionCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class RoomsRequestImpl implements IRoomsRequest { +export class RoomsRequestImpl implements WebsocketTypes.IRoomsRequest { joinRoom(roomId: number): void { SessionCommands.joinRoom(roomId); } diff --git a/webclient/src/api/request/SessionRequestImpl.ts b/webclient/src/api/request/SessionRequestImpl.ts index c7b0e267a..d0ddf472f 100644 --- a/webclient/src/api/request/SessionRequestImpl.ts +++ b/webclient/src/api/request/SessionRequestImpl.ts @@ -1,7 +1,7 @@ -import type { ISessionRequest } from '@app/websocket'; import { SessionCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class SessionRequestImpl implements ISessionRequest { +export class SessionRequestImpl implements WebsocketTypes.ISessionRequest { addToBuddyList(userName: string): void { SessionCommands.addToBuddyList(userName); } diff --git a/webclient/src/api/request/index.ts b/webclient/src/api/request/index.ts index b5934d72a..77093ff38 100644 --- a/webclient/src/api/request/index.ts +++ b/webclient/src/api/request/index.ts @@ -1,4 +1,4 @@ -import type { IWebClientRequest } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { AuthenticationRequestImpl } from './AuthenticationRequestImpl'; import { SessionRequestImpl } from './SessionRequestImpl'; @@ -9,7 +9,7 @@ import { ModeratorRequestImpl } from './ModeratorRequestImpl'; export { AuthenticationRequestImpl, SessionRequestImpl, RoomsRequestImpl, GameRequestImpl, AdminRequestImpl, ModeratorRequestImpl }; -export function createWebClientRequest(): IWebClientRequest { +export function createWebClientRequest(): WebsocketTypes.IWebClientRequest { return { authentication: new AuthenticationRequestImpl(), session: new SessionRequestImpl(), diff --git a/webclient/src/api/response/AdminResponseImpl.ts b/webclient/src/api/response/AdminResponseImpl.ts index a47b0f40b..511357bee 100644 --- a/webclient/src/api/response/AdminResponseImpl.ts +++ b/webclient/src/api/response/AdminResponseImpl.ts @@ -1,7 +1,7 @@ -import type { IAdminResponse } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { ServerDispatch } from '@app/store'; -export class AdminResponseImpl implements IAdminResponse { +export class AdminResponseImpl implements WebsocketTypes.IAdminResponse { adjustMod(userName: string, shouldBeMod: boolean, shouldBeJudge: boolean): void { ServerDispatch.adjustMod(userName, shouldBeMod, shouldBeJudge); } diff --git a/webclient/src/api/response/GameResponseImpl.ts b/webclient/src/api/response/GameResponseImpl.ts index cb8b9fef7..723b8dbcb 100644 --- a/webclient/src/api/response/GameResponseImpl.ts +++ b/webclient/src/api/response/GameResponseImpl.ts @@ -1,8 +1,8 @@ import { Data } from '@app/types'; -import type { IGameResponse } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { GameDispatch } from '@app/store'; -export class GameResponseImpl implements IGameResponse { +export class GameResponseImpl implements WebsocketTypes.IGameResponse { clearStore(): void { GameDispatch.clearStore(); } @@ -35,8 +35,8 @@ export class GameResponseImpl implements IGameResponse { GameDispatch.kicked(gameId); } - gameSay(gameId: number, playerId: number, message: string): void { - GameDispatch.gameSay(gameId, playerId, message); + gameSay(gameId: number, playerId: number, message: string, timeReceived: number): void { + GameDispatch.gameSay(gameId, playerId, message, timeReceived); } cardMoved(gameId: number, playerId: number, data: Data.Event_MoveCard): void { diff --git a/webclient/src/api/response/ModeratorResponseImpl.ts b/webclient/src/api/response/ModeratorResponseImpl.ts index c855152af..6bb9b2b8a 100644 --- a/webclient/src/api/response/ModeratorResponseImpl.ts +++ b/webclient/src/api/response/ModeratorResponseImpl.ts @@ -1,8 +1,8 @@ import { Data } from '@app/types'; -import type { IModeratorResponse } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { ServerDispatch } from '@app/store'; -export class ModeratorResponseImpl implements IModeratorResponse { +export class ModeratorResponseImpl implements WebsocketTypes.IModeratorResponse { banFromServer(userName: string): void { ServerDispatch.banFromServer(userName); } diff --git a/webclient/src/api/response/RoomResponseImpl.ts b/webclient/src/api/response/RoomResponseImpl.ts index d450158c0..38a8ee7d4 100644 --- a/webclient/src/api/response/RoomResponseImpl.ts +++ b/webclient/src/api/response/RoomResponseImpl.ts @@ -1,10 +1,10 @@ import { Data } from '@app/types'; -import type { IRoomResponse, WebSocketRoomResponseOverrides } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { RoomsDispatch } from '@app/store'; -type Message = WebSocketRoomResponseOverrides['Event_RoomSay']; +type Message = WebsocketTypes.WebSocketRoomResponseOverrides['Event_RoomSay']; -export class RoomResponseImpl implements IRoomResponse { +export class RoomResponseImpl implements WebsocketTypes.IRoomResponse { clearStore(): void { RoomsDispatch.clearStore(); } diff --git a/webclient/src/api/response/SessionResponseImpl.ts b/webclient/src/api/response/SessionResponseImpl.ts index 59892d08d..150a942f7 100644 --- a/webclient/src/api/response/SessionResponseImpl.ts +++ b/webclient/src/api/response/SessionResponseImpl.ts @@ -1,12 +1,11 @@ import { Data } from '@app/types'; -import type { ISessionResponse, WebSocketSessionResponseOverrides } from '@app/websocket'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store'; -type LoginSuccess = WebSocketSessionResponseOverrides['Response_Login']; -type PendingActivation = WebSocketSessionResponseOverrides['Response']; +type LoginSuccess = WebsocketTypes.WebSocketSessionResponseOverrides['Response_Login']; +type PendingActivation = WebsocketTypes.WebSocketSessionResponseOverrides['Response']; -export class SessionResponseImpl implements ISessionResponse { +export class SessionResponseImpl implements WebsocketTypes.ISessionResponse { initialized(): void { ServerDispatch.initialized(); } @@ -67,8 +66,8 @@ export class SessionResponseImpl implements ISessionResponse { return ( diff --git a/webclient/src/components/Guard/AuthGuard.spec.tsx b/webclient/src/components/Guard/AuthGuard.spec.tsx new file mode 100644 index 000000000..92368cbab --- /dev/null +++ b/webclient/src/components/Guard/AuthGuard.spec.tsx @@ -0,0 +1,33 @@ +import { screen } from '@testing-library/react'; +import { renderWithProviders, connectedState, disconnectedState } from '../../__test-utils__'; +import AuthGuard from './AuthGuard'; + +vi.mock('@app/hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useWebClient: vi.fn(() => ({})) }; +}); + +describe('AuthGuard', () => { + it('redirects to LOGIN when disconnected', () => { + renderWithProviders(, { + preloadedState: disconnectedState, + route: '/server', + }); + + // Navigate triggers a route change — AuthGuard itself renders no text. + // We verify it doesn't render any meaningful content. + expect(screen.queryByRole('button')).toBeNull(); + expect(screen.queryByRole('heading')).toBeNull(); + }); + + it('renders nothing visible when connected', () => { + const { container } = renderWithProviders(, { + preloadedState: connectedState, + route: '/server', + }); + + // AuthGuard renders an empty fragment when connected. + // The only DOM is from provider wrappers (e.g. ToastProvider's container div). + expect(container.textContent).toBe(''); + }); +}); diff --git a/webclient/src/components/Guard/AuthGuard.tsx b/webclient/src/components/Guard/AuthGuard.tsx index 4bbb8e1d5..897556613 100644 --- a/webclient/src/components/Guard/AuthGuard.tsx +++ b/webclient/src/components/Guard/AuthGuard.tsx @@ -8,7 +8,7 @@ const AuthGuard = () => { const isConnected = useAppSelector(ServerSelectors.getIsConnected); return !isConnected ? - :
; + : <>; }; export default AuthGuard; diff --git a/webclient/src/components/Guard/ModGuard.spec.tsx b/webclient/src/components/Guard/ModGuard.spec.tsx new file mode 100644 index 000000000..962e74b33 --- /dev/null +++ b/webclient/src/components/Guard/ModGuard.spec.tsx @@ -0,0 +1,38 @@ +import { renderWithProviders, connectedState, makeUser } from '../../__test-utils__'; +import { Data } from '@app/types'; +import ModGuard from './ModGuard'; + +vi.mock('@app/hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useWebClient: vi.fn(() => ({})) }; +}); + +describe('ModGuard', () => { + it('redirects when user is not a moderator', () => { + const { container } = renderWithProviders(, { + preloadedState: connectedState, + route: '/logs', + }); + + expect(container.textContent).toBe(''); + }); + + it('renders nothing visible when user is a moderator', () => { + const modUser = makeUser({ + userLevel: Data.ServerInfo_User_UserLevelFlag.IsModerator, + }); + + const { container } = renderWithProviders(, { + preloadedState: { + ...connectedState, + server: { + ...(connectedState.server as any), + user: modUser, + }, + }, + route: '/logs', + }); + + expect(container.textContent).toBe(''); + }); +}); diff --git a/webclient/src/components/InputField/InputField.spec.tsx b/webclient/src/components/InputField/InputField.spec.tsx new file mode 100644 index 000000000..1f535a175 --- /dev/null +++ b/webclient/src/components/InputField/InputField.spec.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import InputField from './InputField'; + +describe('InputField', () => { + const defaultProps = { + input: { name: 'test', value: '', onChange: vi.fn(), onBlur: vi.fn(), onFocus: vi.fn() }, + meta: { touched: false, error: null, warning: null }, + label: 'Test Field', + }; + + it('renders a text field with label', () => { + render(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('shows error when touched and has error', () => { + render(); + expect(screen.getByText('Required')).toBeInTheDocument(); + }); + + it('shows warning when touched and has warning', () => { + render(); + expect(screen.getByText('Weak password')).toBeInTheDocument(); + }); + + it('does not show validation messages when not touched', () => { + render(); + expect(screen.queryByText('Required')).not.toBeInTheDocument(); + }); +}); diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index e233bc5ae..416de9875 100644 --- a/webclient/src/components/KnownHosts/KnownHosts.tsx +++ b/webclient/src/components/KnownHosts/KnownHosts.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { Select, MenuItem } from '@mui/material'; @@ -13,10 +13,9 @@ import AddIcon from '@mui/icons-material/Add'; import EditRoundedIcon from '@mui/icons-material/Edit'; import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; -import { useWebClient } from '@app/hooks'; +import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { KnownHostDialog } from '@app/dialogs'; -import { useReduxEffect } from '@app/hooks'; -import { HostDTO } from '@app/services'; +import { getHostPort, HostDTO } from '@app/services'; import { ServerTypes } from '@app/store'; import { App } from '@app/types'; import Toast from '../Toast/Toast'; @@ -32,244 +31,215 @@ enum TestConnection { const PREFIX = 'KnownHosts'; const classes = { - root: `${PREFIX}-root` + root: `${PREFIX}-root`, }; const Root = styled('div')(({ theme }) => ({ [`&.${classes.root}`]: { '& .KnownHosts-error': { - color: theme.palette.error.main + color: theme.palette.error.main, }, '& .KnownHosts-warning': { - color: theme.palette.warning.main + color: theme.palette.warning.main, }, '& .KnownHosts-item': { [`& .${TestConnection.TESTING}`]: { - color: theme.palette.warning.main + color: theme.palette.warning.main, }, [`& .${TestConnection.FAILED}`]: { - color: theme.palette.error.main + color: theme.palette.error.main, }, [`& .${TestConnection.SUCCESS}`]: { - color: theme.palette.success.main - } - } - } + color: theme.palette.success.main, + }, + }, + }, })); - -const KnownHosts = (props) => { - const { input: { onChange }, meta, disabled } = props; +const KnownHosts = (props: any) => { + const { input, meta, disabled } = props; + const onChange: (value: HostDTO) => void = input.onChange; const { touched, error, warning } = meta; const { t } = useTranslation(); const webClient = useWebClient(); + const knownHosts = useKnownHosts(); - const [hostsState, setHostsState] = useState({ - hosts: [], - selectedHost: {} as any, - }); - - const [dialogState, setDialogState] = useState({ + const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({ open: false, edit: null, }); - const [testingConnection, setTestingConnection] = useState(null); + const [testingConnection, setTestingConnection] = useState(null); const [showCreateToast, setShowCreateToast] = useState(false); const [showDeleteToast, setShowDeleteToast] = useState(false); const [showEditToast, setShowEditToast] = useState(false); - const loadKnownHosts = useCallback(async () => { - const hosts = await HostDTO.getAll(); + const selectedHost = + knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined; + const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : []; - if (!hosts?.length) { - // @TODO: find a better pattern to seeding default data in indexedDB - await HostDTO.bulkAdd(App.DefaultHosts); - loadKnownHosts(); - } else { - const selectedHost = hosts.find(({ lastSelected }) => lastSelected) || hosts[0]; - setHostsState(s => ({ ...s, hosts, selectedHost })); - } - }, []); + const testConnection = (host: HostDTO) => { + setTestingConnection(TestConnection.TESTING); + webClient.request.authentication.testConnection({ ...getHostPort(host) }); + }; + // Mirror the store's selectedHost into the form field. Also kick off a + // connection test so the user sees the green/red indicator on mount. useEffect(() => { - loadKnownHosts(); - }, [loadKnownHosts]); - - useEffect(() => { - const { selectedHost } = hostsState; - - if (selectedHost?.id) { - updateLastSelectedHost(selectedHost.id).then(() => { - onChange(selectedHost); - }); + if (!selectedHost) { + return; } - }, [hostsState, onChange]); + onChange(selectedHost); + testConnection(selectedHost); + }, [selectedHost]); - useReduxEffect(() => { - setTestingConnection(TestConnection.SUCCESS); - }, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []); + useReduxEffect( + () => { + setTestingConnection(TestConnection.SUCCESS); + }, + ServerTypes.TEST_CONNECTION_SUCCESSFUL, + [] + ); - useReduxEffect(() => { - setTestingConnection(TestConnection.FAILED); - }, ServerTypes.TEST_CONNECTION_FAILED, []); + useReduxEffect( + () => { + setTestingConnection(TestConnection.FAILED); + }, + ServerTypes.TEST_CONNECTION_FAILED, + [] + ); - const selectHost = (selectedHost) => { - setHostsState(s => ({ ...s, selectedHost })); + const onPick = async (host: HostDTO) => { + if (knownHosts.status !== LoadingState.READY) { + return; + } + onChange(host); + await knownHosts.select(host.id!); + testConnection(host); }; const openAddKnownHostDialog = () => { - setDialogState(s => ({ ...s, open: true, edit: null })); + setDialogState((s) => ({ ...s, open: true, edit: null })); }; const openEditKnownHostDialog = (host: HostDTO) => { - setDialogState(s => ({ ...s, open: true, edit: host })); + setDialogState((s) => ({ ...s, open: true, edit: host })); }; const closeKnownHostDialog = () => { - setDialogState(s => ({ ...s, open: false })); - } - - const handleDialogRemove = async ({ id }) => { - setHostsState(s => ({ - ...s, - hosts: s.hosts.filter(host => host.id !== id), - selectedHost: s.selectedHost.id === id ? s.hosts[0] : s.selectedHost, - })); - - closeKnownHostDialog(); - HostDTO.delete(id); - setShowDeleteToast(true) + setDialogState((s) => ({ ...s, open: false })); }; - const handleDialogSubmit = async ({ id, name, host, port }) => { - if (id) { - const hostDTO = await HostDTO.get(id); - hostDTO.name = name; - hostDTO.host = host; - hostDTO.port = port; - await hostDTO.save(); + const handleDialogRemove = async ({ id }: { id: number }) => { + if (knownHosts.status !== LoadingState.READY) { + return; + } + await knownHosts.remove(id); + closeKnownHostDialog(); + setShowDeleteToast(true); + }; - setHostsState(s => ({ - ...s, - hosts: s.hosts.map(h => h.id === id ? hostDTO : h), - selectedHost: hostDTO - })); - setShowEditToast(true) + const handleDialogSubmit = async ({ + id, + name, + host, + port, + }: { + id?: number; + name: string; + host: string; + port: string; + }) => { + if (knownHosts.status !== LoadingState.READY) { + return; + } + + if (id) { + await knownHosts.update(id, { name, host, port }); + setShowEditToast(true); } else { const newHost: App.Host = { name, host, port, editable: true }; - newHost.id = await HostDTO.add(newHost) as number; - - setHostsState(s => ({ - ...s, - hosts: [...s.hosts, newHost], - selectedHost: newHost, - })); - setShowCreateToast(true) + await knownHosts.add(newHost); + setShowCreateToast(true); } closeKnownHostDialog(); }; - const updateLastSelectedHost = (hostId): Promise => { - testConnection(); - - return HostDTO.getAll().then(hosts => - hosts.map(async host => { - if (host.id === hostId) { - host.lastSelected = true; - return await host.save(); - } - - if (host.lastSelected) { - host.lastSelected = false; - return await host.save(); - } - - return host; - }) - ); - }; - - const testConnection = () => { - setTestingConnection(TestConnection.TESTING); - - const options = { ...App.getHostPort(hostsState.selectedHost) }; - webClient.request.authentication.testConnection(options); - } - return ( - - { touched && ( -
- { - (error && -
- {error} - -
- ) || - - (warning &&
{warning}
) - } + + {touched && ( +
+ {(error && ( +
+ {error} + +
+ )) || + (warning &&
{warning}
)}
- ) } + )} - { t('KnownHosts.label') } + {t('KnownHosts.label')}
@@ -280,9 +250,15 @@ const KnownHosts = (props) => { onSubmit={handleDialogSubmit} handleClose={closeKnownHostDialog} /> - setShowCreateToast(false)}>{ t('KnownHosts.toast', { mode: 'created' }) } - setShowDeleteToast(false)}>{ t('KnownHosts.toast', { mode: 'deleted' }) } - setShowEditToast(false)}>{ t('KnownHosts.toast', { mode: 'edited' }) } + setShowCreateToast(false)}> + {t('KnownHosts.toast', { mode: 'created' })} + + setShowDeleteToast(false)}> + {t('KnownHosts.toast', { mode: 'deleted' })} + + setShowEditToast(false)}> + {t('KnownHosts.toast', { mode: 'edited' })} + ); }; diff --git a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx index d48aa2e7b..d98defb2b 100644 --- a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx +++ b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx @@ -11,7 +11,9 @@ import './LanguageDropdown.css'; const LanguageDropdown = () => { const { t, i18n } = useTranslation(); - const [language, setLanguage] = useState(i18n.resolvedLanguage); + // i18next `resolvedLanguage` is undefined until a registered resource matches; + // MUI Select requires a concrete, in-range value. + const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? ''); useEffect(() => { if (language !== i18n.resolvedLanguage) { diff --git a/webclient/src/components/Message/Message.spec.tsx b/webclient/src/components/Message/Message.spec.tsx new file mode 100644 index 000000000..542760127 --- /dev/null +++ b/webclient/src/components/Message/Message.spec.tsx @@ -0,0 +1,19 @@ +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../../__test-utils__'; +import Message from './Message'; + +describe('Message', () => { + it('renders a plain message', () => { + const message = { message: 'Hello world' }; + renderWithProviders(); + + expect(screen.getByText('Hello world')).toBeInTheDocument(); + }); + + it('renders the message container', () => { + const message = { message: 'Test message' }; + const { container } = renderWithProviders(); + + expect(container.querySelector('.message')).toBeInTheDocument(); + }); +}); diff --git a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx index 939a7d753..95f10f62c 100644 --- a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx +++ b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useRef } from 'react'; const ScrollToBottomOnChanges = ({ content, changes }) => { const messagesEndRef = useRef(null); - // @TODO (2) const scrollToBottom = () => { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) } diff --git a/webclient/src/components/ThreePaneLayout/ThreePaneLayout.tsx b/webclient/src/components/ThreePaneLayout/ThreePaneLayout.tsx index 287e58382..0e12cd8ec 100644 --- a/webclient/src/components/ThreePaneLayout/ThreePaneLayout.tsx +++ b/webclient/src/components/ThreePaneLayout/ThreePaneLayout.tsx @@ -3,8 +3,7 @@ import Grid from '@mui/material/Grid'; import './ThreePaneLayout.css'; -// @DEPRECATED -// This component sucks balls, dont use it. It will be removed sooner than later. +/** @deprecated Scheduled for replacement with a more flexible layout component. */ function ThreePaneLayout(props: ThreePaneLayoutProps) { return (
diff --git a/webclient/src/components/UserDisplay/UserDisplay.spec.tsx b/webclient/src/components/UserDisplay/UserDisplay.spec.tsx new file mode 100644 index 000000000..67c4dce8d --- /dev/null +++ b/webclient/src/components/UserDisplay/UserDisplay.spec.tsx @@ -0,0 +1,46 @@ +import { screen } from '@testing-library/react'; +import { renderWithProviders, connectedState, makeUser, createMockWebClient } from '../../__test-utils__'; +import UserDisplay from './UserDisplay'; + +const mockWebClient = createMockWebClient(); + +vi.mock('@app/hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useWebClient: vi.fn(() => mockWebClient) }; +}); + +vi.mock('@app/images', () => ({ + Images: { Countries: { us: 'us.png', de: 'de.png' } }, +})); + +describe('UserDisplay', () => { + it('renders user name', () => { + const user = makeUser({ name: 'TestPlayer', country: 'us' }); + renderWithProviders(, { + preloadedState: connectedState, + }); + + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + it('renders country flag image', () => { + const user = makeUser({ name: 'TestPlayer', country: 'us' }); + renderWithProviders(, { + preloadedState: connectedState, + }); + + const img = screen.getByAltText('us'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'us.png'); + }); + + it('renders link to player profile', () => { + const user = makeUser({ name: 'TestPlayer', country: 'us' }); + renderWithProviders(, { + preloadedState: connectedState, + }); + + const link = screen.getByRole('link', { name: /TestPlayer/ }); + expect(link).toHaveAttribute('href', '/player/TestPlayer'); + }); +}); diff --git a/webclient/src/components/VirtualList/VirtualList.spec.tsx b/webclient/src/components/VirtualList/VirtualList.spec.tsx new file mode 100644 index 000000000..1e59b7a1a --- /dev/null +++ b/webclient/src/components/VirtualList/VirtualList.spec.tsx @@ -0,0 +1,21 @@ +import { render } from '@testing-library/react'; +import VirtualList from './VirtualList'; + +describe('VirtualList', () => { + it('renders without crashing with empty items', () => { + const { container } = render(); + expect(container.querySelector('.virtual-list')).toBeInTheDocument(); + }); + + it('accepts className as a string', () => { + const { container } = render(); + expect(container.querySelector('.custom-class')).toBeInTheDocument(); + }); + + it('applies empty string as default className (not object)', () => { + const { container } = render(); + const list = container.querySelector('.virtual-list__list'); + // className should not contain "[object Object]" + expect(list?.className).not.toContain('[object Object]'); + }); +}); diff --git a/webclient/src/components/VirtualList/VirtualList.tsx b/webclient/src/components/VirtualList/VirtualList.tsx index fc2868f2d..90c15436e 100644 --- a/webclient/src/components/VirtualList/VirtualList.tsx +++ b/webclient/src/components/VirtualList/VirtualList.tsx @@ -15,7 +15,7 @@ const Row = ({ index, style, items }: RowComponentProps) => (
); -const VirtualList = ({ items, className = {}, size = 30 }) => ( +const VirtualList = ({ items, className = '', size = 30 }) => (
className={`virtual-list__list ${className}`} diff --git a/webclient/src/containers/Account/Account.tsx b/webclient/src/containers/Account/Account.tsx index ee06f590a..3902b31d6 100644 --- a/webclient/src/containers/Account/Account.tsx +++ b/webclient/src/containers/Account/Account.tsx @@ -1,5 +1,4 @@ -// eslint-disable-next-line -import React, { Component } from "react"; +import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; @@ -25,7 +24,21 @@ const Account = () => { const user = useAppSelector(state => ServerSelectors.getUser(state)); const webClient = useWebClient(); const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {}; - let url = URL.createObjectURL(new Blob([avatarBmp as BlobPart], { 'type': 'image/png' })); + + const avatarUrl = useMemo(() => { + if (!avatarBmp) { + return ''; + } + return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' })); + }, [avatarBmp]); + + useEffect(() => { + return () => { + if (avatarUrl) { + URL.revokeObjectURL(avatarUrl); + } + }; + }, [avatarUrl]); const { t } = useTranslation(); @@ -42,41 +55,41 @@ const Account = () => {
-
+
Buddies Online: ?/{buddyList.length}
( - + )) } /> -
+
-
+
Ignored Users Online: ?/{ignoreList.length}
( - + )) } /> -
+
- {name} + { avatarUrl && {name} }

{name}

Location: ({country?.toUpperCase()})

User Level: {userLevel}

@@ -95,7 +108,7 @@ const Account = () => { diff --git a/webclient/src/containers/App/AppShell.tsx b/webclient/src/containers/App/AppShell.tsx index 66618d865..4a0856bc5 100644 --- a/webclient/src/containers/App/AppShell.tsx +++ b/webclient/src/containers/App/AppShell.tsx @@ -12,7 +12,6 @@ import { ToastProvider } from '@app/components' function AppShell() { useEffect(() => { - // @TODO (1) window.onbeforeunload = () => true; }, []); diff --git a/webclient/src/containers/Login/Login.spec.tsx b/webclient/src/containers/Login/Login.spec.tsx new file mode 100644 index 000000000..e2a398066 --- /dev/null +++ b/webclient/src/containers/Login/Login.spec.tsx @@ -0,0 +1,177 @@ +import { act, waitFor } from '@testing-library/react'; + +import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__'; + +const flushEffects = async (): Promise => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; +import { makeSettings, makeSettingsHook } from '../../hooks/__mocks__/useSettings'; +import { makeHost, makeKnownHostsHook } from '../../hooks/__mocks__/useKnownHosts'; +import { autoLoginGate } from '../../hooks/useAutoLogin'; +import { LoadingState } from '@app/hooks'; +import Login from './Login'; + +const hoisted = vi.hoisted(() => ({ + mockWebClient: undefined as any, + getSettings: vi.fn(), + getKnownHosts: vi.fn(), + useSettings: vi.fn(), + useKnownHosts: vi.fn(), +})); + +vi.mock('../../hooks/useSettings', () => ({ + useSettings: hoisted.useSettings, + getSettings: hoisted.getSettings, +})); +vi.mock('../../hooks/useKnownHosts', () => ({ + useKnownHosts: hoisted.useKnownHosts, + getKnownHosts: hoisted.getKnownHosts, +})); +vi.mock('../../hooks/useWebClient', () => ({ + useWebClient: () => hoisted.mockWebClient, + WebClientProvider: ({ children }: { children: any }) => children, +})); + +beforeAll(() => { + const client = createMockWebClient(); + (client.request.authentication as any).testConnection = vi.fn(); + hoisted.mockWebClient = client; +}); + +afterEach(async () => { + await flushEffects(); +}); + +beforeEach(() => { + autoLoginGate.hasChecked = false; + + hoisted.getSettings.mockReset(); + hoisted.getKnownHosts.mockReset(); + hoisted.useSettings.mockReset(); + hoisted.useKnownHosts.mockReset(); + + const defaultHost = makeHost({ + id: 1, + remember: true, + userName: 'alice', + hashedPassword: 'stored-hash', + lastSelected: true, + }); + + hoisted.useSettings.mockReturnValue( + makeSettingsHook({ + status: LoadingState.READY, + value: makeSettings({ autoConnect: false }), + update: vi.fn().mockResolvedValue(undefined), + }) + ); + hoisted.useKnownHosts.mockReturnValue( + makeKnownHostsHook({ + status: LoadingState.READY, + value: { hosts: [defaultHost], selectedHost: defaultHost }, + }) + ); + hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: false })); + hoisted.getKnownHosts.mockResolvedValue({ + hosts: [defaultHost], + selectedHost: defaultHost, + }); +}); + +const armAutoConnect = () => { + const host = makeHost({ + id: 1, + remember: true, + userName: 'alice', + hashedPassword: 'stored-hash', + lastSelected: true, + }); + hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); + hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host }); +}; + +describe('Login — auto-connect cold start', () => { + test('fires login when settings + host say go', async () => { + armAutoConnect(); + + renderWithProviders(, { preloadedState: disconnectedState }); + + await waitFor(() => { + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + expect(hoisted.mockWebClient.request.authentication.login.mock.calls[0][0]).toMatchObject({ + userName: 'alice', + hashedPassword: 'stored-hash', + }); + }); + + test('does not fire when autoConnect setting is off', async () => { + renderWithProviders(, { preloadedState: disconnectedState }); + + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + }); + + test('does not fire when selected host has no stored credentials', async () => { + const host = makeHost({ + id: 1, + remember: false, + userName: undefined, + hashedPassword: undefined, + lastSelected: true, + }); + hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); + hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host }); + + renderWithProviders(, { preloadedState: disconnectedState }); + + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + }); +}); + +describe('Login — logout cycle (same JS session)', () => { + test('does not re-auto-connect after first auto-login + logout', async () => { + armAutoConnect(); + + const first = renderWithProviders(, { preloadedState: disconnectedState }); + await waitFor(() => { + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + + first.unmount(); + renderWithProviders(, { preloadedState: disconnectedState }); + + await flushEffects(); + + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + + test('does not auto-connect when user enabled autoConnect mid-session and then logged out', async () => { + const first = renderWithProviders(, { preloadedState: disconnectedState }); + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + + first.unmount(); + + hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); + + renderWithProviders(, { preloadedState: disconnectedState }); + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + }); +}); + +describe('Login — refresh cycle', () => { + test('a fresh session gate re-fires auto-login when conditions still hold', async () => { + armAutoConnect(); + + renderWithProviders(, { preloadedState: disconnectedState }); + + await waitFor(() => { + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index 70896ce77..78bcdf242 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { Navigate } from 'react-router-dom'; @@ -9,10 +9,11 @@ import Typography from '@mui/material/Typography'; import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs'; import { LanguageDropdown } from '@app/components'; import { LoginForm } from '@app/forms'; -import { useReduxEffect, useFireOnce, useWebClient } from '@app/hooks'; +import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { Images } from '@app/images'; -import { HostDTO, serverProps } from '@app/services'; -import { App, Enriched } from '@app/types'; +import { getHostPort, serverProps } from '@app/services'; +import { App } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { ServerSelectors, ServerTypes } from '@app/store'; import Layout from '../Layout/Layout'; import { useAppSelector } from '@app/store'; @@ -66,12 +67,14 @@ const Root = styled('div')(({ theme }) => ({ const Login = () => { const description = useAppSelector(s => ServerSelectors.getDescription(s)); const isConnected = useAppSelector(ServerSelectors.getIsConnected); + const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade); const webClient = useWebClient(); const { t } = useTranslation(); - const [pendingActivationOptions, setPendingActivationOptions] = useState(null); + const [pendingActivationOptions, setPendingActivationOptions] = useState(null); - const [rememberLogin, setRememberLogin] = useState(null); + const rememberLoginRef = useRef(null); + const knownHosts = useKnownHosts(); const [dialogState, setDialogState] = useState({ passwordResetRequestDialog: false, resetPasswordDialog: false, @@ -113,19 +116,21 @@ const Login = () => { }, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []); useReduxEffect(({ payload: { options: { hashedPassword } } }) => { - updateHost(hashedPassword, rememberLogin); - }, ServerTypes.LOGIN_SUCCESSFUL, [rememberLogin]); + if (rememberLoginRef.current) { + updateHost(hashedPassword, rememberLoginRef.current); + } + }, ServerTypes.LOGIN_SUCCESSFUL, []); const showDescription = () => { return !isConnected && description?.length; }; const onSubmitLogin = useCallback((loginForm) => { - setRememberLogin(loginForm); + rememberLoginRef.current = loginForm; const { userName, password, selectedHost, remember } = loginForm; - const options: Omit = { - ...App.getHostPort(selectedHost), + const options: Omit = { + ...getHostPort(selectedHost), userName, password, }; @@ -139,22 +144,22 @@ const Login = () => { const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin); - const updateHost = (hashedPassword, { selectedHost, remember, userName }) => { - HostDTO.get(selectedHost.id).then(hostDTO => { - hostDTO.remember = remember; - hostDTO.userName = remember ? userName : null; - hostDTO.hashedPassword = remember ? hashedPassword : null; + useAutoLogin(handleLogin, connectionAttemptMade); - hostDTO.save(); + const updateHost = (hashedPassword, { selectedHost, remember, userName }) => { + knownHosts.update(selectedHost.id, { + remember, + userName: remember ? userName : null, + hashedPassword: remember ? hashedPassword : null, }); }; const handleRegistrationDialogSubmit = (registerForm) => { - setRememberLogin(registerForm); + rememberLoginRef.current = registerForm; const { userName, password, email, country, realName, selectedHost } = registerForm; webClient.request.authentication.register({ - ...App.getHostPort(selectedHost), + ...getHostPort(selectedHost), userName, password, email, @@ -177,7 +182,7 @@ const Login = () => { const handleRequestPasswordResetDialogSubmit = (form) => { const { userName, email, selectedHost } = form; - const { host, port } = App.getHostPort(selectedHost); + const { host, port } = getHostPort(selectedHost); if (email) { webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port }); @@ -188,7 +193,7 @@ const Login = () => { }; const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => { - const { host, port } = App.getHostPort(selectedHost); + const { host, port } = getHostPort(selectedHost); webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port }); }; diff --git a/webclient/src/containers/Logs/Logs.tsx b/webclient/src/containers/Logs/Logs.tsx index 8ab9a181b..bf2fe9a6d 100644 --- a/webclient/src/containers/Logs/Logs.tsx +++ b/webclient/src/containers/Logs/Logs.tsx @@ -1,5 +1,4 @@ -// eslint-disable-next-line -import React, { useEffect } from "react"; +import { useEffect } from 'react'; import { AuthGuard, ModGuard } from '@app/components'; import { SearchForm } from '@app/forms'; diff --git a/webclient/src/containers/Room/OpenGames.spec.tsx b/webclient/src/containers/Room/OpenGames.spec.tsx new file mode 100644 index 000000000..6da3edf90 --- /dev/null +++ b/webclient/src/containers/Room/OpenGames.spec.tsx @@ -0,0 +1,35 @@ +import { screen } from '@testing-library/react'; +import { renderWithProviders, connectedWithRoomsState } from '../../__test-utils__'; +import OpenGames from './OpenGames'; + +vi.mock('@app/hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useWebClient: vi.fn(() => ({})) }; +}); + +describe('OpenGames', () => { + const roomWithGames = { + info: { roomId: 1, name: 'Main Room' }, + }; + + it('renders the games table headers', () => { + renderWithProviders(, { + preloadedState: connectedWithRoomsState, + }); + + expect(screen.getByText('Age')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Creator')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Players')).toBeInTheDocument(); + expect(screen.getByText('Spectators')).toBeInTheDocument(); + }); + + it('renders without crashing when no games exist', () => { + const { container } = renderWithProviders(, { + preloadedState: connectedWithRoomsState, + }); + + expect(container.querySelector('.games')).toBeInTheDocument(); + }); +}); diff --git a/webclient/src/containers/Room/OpenGames.tsx b/webclient/src/containers/Room/OpenGames.tsx index d5ab68f6f..59e721aa1 100644 --- a/webclient/src/containers/Room/OpenGames.tsx +++ b/webclient/src/containers/Room/OpenGames.tsx @@ -1,5 +1,4 @@ -// eslint-disable-next-line -import React from "react"; +import React from 'react'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -42,17 +41,17 @@ const OpenGames = ({ room }: OpenGamesProps) => { RoomsDispatch.sortGames(roomId, field, order); }; - const isUnavailableGame = ({ started, maxPlayers, playerCount }) => + const isAvailable = ({ started, maxPlayers, playerCount }) => !started && playerCount < maxPlayers; - const isPasswordProtectedGame = ({ withPassword }) => !withPassword; + const isOpen = ({ withPassword }) => !withPassword; - const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies; + const isPublic = ({ onlyBuddies }) => !onlyBuddies; const games = sortedGames.filter(game => ( - isUnavailableGame(game.info) && - isPasswordProtectedGame(game.info) && - isBuddiesOnlyGame(game.info) + isAvailable(game.info) && + isOpen(game.info) && + isPublic(game.info) )); return ( diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index 5877c57b5..2da6b2871 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -17,7 +17,6 @@ import SayMessage from './SayMessage'; import './Room.css'; -// @TODO (3) const Room = () => { const joined = useAppSelector(state => RoomsSelectors.getJoinedRooms(state)); const rooms = useAppSelector(state => RoomsSelectors.getRooms(state)); @@ -25,7 +24,7 @@ const Room = () => { const navigate = useNavigate(); const params = useParams(); - const roomId = parseInt(params.roomId, 0); + const roomId = parseInt(params.roomId, 10); const room = rooms[roomId]; const roomMessages = messages[roomId]; const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId)); @@ -37,6 +36,10 @@ const Room = () => { } }, [joined]); + if (!room) { + return null; + } + const handleRoomSay = ({ message }) => { if (message) { webClient.request.rooms.roomSay(roomId, message); @@ -78,7 +81,7 @@ const Room = () => { ( - + )) } diff --git a/webclient/src/containers/Server/Server.tsx b/webclient/src/containers/Server/Server.tsx index d19e829bc..1620138fc 100644 --- a/webclient/src/containers/Server/Server.tsx +++ b/webclient/src/containers/Server/Server.tsx @@ -1,5 +1,4 @@ -// eslint-disable-next-line -import React from "react"; +import React from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; import ListItemButton from '@mui/material/ListItemButton'; @@ -40,6 +39,7 @@ const Server = () => { bottom={( + {/* message is sanitized via DOMPurify in websocket/events/session/serverMessage.ts */}
)} @@ -51,7 +51,7 @@ const Server = () => {
( - + )) } diff --git a/webclient/src/forms/LoginForm/LoginForm.spec.tsx b/webclient/src/forms/LoginForm/LoginForm.spec.tsx new file mode 100644 index 000000000..0dcc8f071 --- /dev/null +++ b/webclient/src/forms/LoginForm/LoginForm.spec.tsx @@ -0,0 +1,97 @@ +import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__'; +import { makeSettingsHook, makeSettings } from '../../hooks/__mocks__/useSettings'; +import { makeKnownHostsHook, makeHost } from '../../hooks/__mocks__/useKnownHosts'; + +const hoisted = vi.hoisted(() => ({ + mockWebClient: undefined as any, + mockUseSettings: vi.fn(), + mockUseKnownHosts: vi.fn(), +})); + +vi.mock('@app/hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useWebClient: () => hoisted.mockWebClient, + useSettings: hoisted.mockUseSettings, + useKnownHosts: hoisted.mockUseKnownHosts, + }; +}); + +import LoginForm from './LoginForm'; +import { LoadingState } from '@app/hooks'; + +beforeAll(() => { + const client = createMockWebClient(); + (client.request.authentication as any).testConnection = vi.fn(); + hoisted.mockWebClient = client; +}); + +describe('LoginForm — regression: settings.autoConnect is not clobbered by host state', () => { + test('selecting a host with remember=false does NOT call settings.update', () => { + const update = vi.fn().mockResolvedValue(undefined); + + hoisted.mockUseSettings.mockReturnValue( + makeSettingsHook({ + status: LoadingState.READY, + value: makeSettings({ autoConnect: true }), + update, + }) + ); + + const host = makeHost({ + id: 1, + remember: false, + userName: undefined, + hashedPassword: undefined, + lastSelected: true, + }); + hoisted.mockUseKnownHosts.mockReturnValue( + makeKnownHostsHook({ + status: LoadingState.READY, + value: { hosts: [host], selectedHost: host }, + }) + ); + + renderWithProviders( + , + { preloadedState: disconnectedState } + ); + + expect(update).not.toHaveBeenCalled(); + }); + + test('auto-login never fires from the form; that is now the container concern', () => { + const onSubmit = vi.fn(); + const update = vi.fn().mockResolvedValue(undefined); + + hoisted.mockUseSettings.mockReturnValue( + makeSettingsHook({ + status: LoadingState.READY, + value: makeSettings({ autoConnect: true }), + update, + }) + ); + + const host = makeHost({ + id: 1, + remember: true, + userName: 'joe', + hashedPassword: 'abc', + lastSelected: true, + }); + hoisted.mockUseKnownHosts.mockReturnValue( + makeKnownHostsHook({ + status: LoadingState.READY, + value: { hosts: [host], selectedHost: host }, + }) + ); + + renderWithProviders( + , + { preloadedState: disconnectedState } + ); + + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/forms/LoginForm/LoginForm.tsx b/webclient/src/forms/LoginForm/LoginForm.tsx index 4d6ee4e37..b09470f55 100644 --- a/webclient/src/forms/LoginForm/LoginForm.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.tsx @@ -1,29 +1,181 @@ import React, { useEffect, useState } from 'react'; -import { Form, Field } from 'react-final-form'; +import { Form, Field, useFormState, FormApi } from 'react-final-form'; import { OnChange } from 'react-final-form-listeners'; import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { CheckboxField, InputField, KnownHosts } from '@app/components'; -import { useAutoConnect } from '@app/hooks'; -import { HostDTO, SettingDTO } from '@app/services'; -import { App } from '@app/types'; -import { useAppSelector, ServerSelectors } from '@app/store'; +import { LoadingState, useKnownHosts, useSettings } from '@app/hooks'; import './LoginForm.css'; -const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginFormProps) => { +interface LoginFormProps { + onSubmit: (values: any) => void; + disableSubmitButton: boolean; + onResetPassword: () => void; +} + +interface LoginFormBodyProps extends LoginFormProps { + form: FormApi; + handleSubmit: (event?: React.SyntheticEvent) => void; +} + +const LoginFormBody = ({ + form, + handleSubmit, + disableSubmitButton, + onResetPassword, +}: LoginFormBodyProps) => { const { t } = useTranslation(); const PASSWORD_LABEL = t('Common.label.password'); const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`; - const [host, setHost] = useState(null); - const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false); - const [autoConnect, setAutoConnect] = useAutoConnect(); - const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade); + const settings = useSettings(); + const hosts = useKnownHosts(); + const { values } = useFormState(); - const validate = values => { + const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined; + + const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false); + const [storedHashInvalidated, setStoredHashInvalidated] = useState(false); + + const canUseStoredPassword = (remember: boolean, password: string | undefined) => + Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated); + + const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on); + + // @critical Host-sync must not touch autoConnect — app-level setting, not per-host. + useEffect(() => { + if (!selectedHost) { + return; + } + + form.change('userName', selectedHost.userName); + form.change('password', ''); + form.change('remember', Boolean(selectedHost.remember)); + + setStoredHashInvalidated(false); + togglePasswordLabel( + Boolean(selectedHost.remember && selectedHost.hashedPassword) + ); + }, [selectedHost, form]); + + useEffect(() => { + if (settings.status !== LoadingState.READY) { + return; + } + form.change('autoConnect', settings.value?.autoConnect); + }, [settings, form]); + + const onUserNameChange = (userName: string | undefined) => { + const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase(); + if (canUseStoredPassword(values.remember, values.password) && fieldChanged) { + setStoredHashInvalidated(true); + } + }; + + const onRememberChange = (checked: boolean) => { + // @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit. + if (!checked && values.autoConnect) { + form.change('autoConnect', false); + } + + togglePasswordLabel(canUseStoredPassword(checked, values.password)); + }; + + // @critical Only persist-path for autoConnect; wired to native onChange, not , + // to avoid leaking form.change() writes into Dexie. + const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => { + fieldOnChange(checked); + + if (settings.status === LoadingState.READY) { + void settings.update({ autoConnect: checked }); + } + + if (checked && !values.remember) { + form.change('remember', true); + } + }; + + return ( +
+
+
+ + {onUserNameChange} +
+
+ setUseStoredPasswordLabel(false)} + onBlur={() => + togglePasswordLabel(canUseStoredPassword(values.remember, values.password)) + } + name="password" + type="password" + component={InputField} + autoComplete="new-password" + /> +
+
+ + {onRememberChange} + + +
+
+ +
+
+ + {({ input }) => ( + onUserToggleAutoConnect(checked, input.onChange)} + color="primary" + /> + } + /> + )} + +
+
+ +
+ ); +}; + +const LoginForm = (props: LoginFormProps) => { + const { t } = useTranslation(); + + const validate = (values: any) => { const errors: any = {}; if (!values.userName) { @@ -34,139 +186,20 @@ const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginForm } return errors; - } - - const useStoredPassword = (remember, password) => remember && host?.hashedPassword && !password; - const togglePasswordLabel = (useStoredLabel) => { - setUseStoredPasswordLabel(useStoredLabel); }; - const handleOnSubmit = ({ userName, ...values }) => { + const handleOnSubmit = ({ userName, ...values }: any) => { userName = userName?.trim(); - console.log(userName, values); - - onSubmit({ userName, ...values }); - } + props.onSubmit({ userName, ...values }); + }; return (
- {({ handleSubmit, form }) => { - const { values } = form.getState(); - - useEffect(() => { - SettingDTO.get(App.APP_USER).then((userSetting: SettingDTO) => { - if (userSetting?.autoConnect && !connectionAttemptMade) { - HostDTO.getAll().then(hosts => { - let lastSelectedHost = hosts.find(({ lastSelected }) => lastSelected); - - if (lastSelectedHost?.remember && lastSelectedHost?.hashedPassword) { - togglePasswordLabel(true); - - form.change('selectedHost', lastSelectedHost); - form.change('userName', lastSelectedHost.userName); - form.change('remember', true); - form.submit(); - } - }); - } - }); - }, []); - - useEffect(() => { - if (!host) { - return; - } - - form.change('userName', host.userName); - form.change('password', ''); - - onRememberChange(host.remember); - onAutoConnectChange(host.remember && autoConnect); - togglePasswordLabel(useStoredPassword(host.remember, values.password)); - }, [host]); - - const onUserNameChange = (userName) => { - const fieldChanged = host?.userName?.toLowerCase() !== values.userName?.toLowerCase(); - if (useStoredPassword(values.remember, values.password) && fieldChanged) { - setHost(({ hashedPassword: _hashedPassword, ...s }) => ({ ...s, userName })); - } - } - - const onRememberChange = (checked) => { - form.change('remember', checked); - - if (!checked && values.autoConnect) { - onAutoConnectChange(false); - } - - togglePasswordLabel(useStoredPassword(checked, values.password)); - } - - const onAutoConnectChange = (checked) => { - setAutoConnect(checked); - - form.change('autoConnect', checked); - - if (checked && !values.remember) { - form.change('remember', true); - } - } - - return ( - -
-
- - {onUserNameChange} -
-
- setUseStoredPasswordLabel(false)} - onBlur={() => togglePasswordLabel(useStoredPassword(values.remember, values.password))} - name='password' - type='password' - component={InputField} - autoComplete='new-password' - /> -
-
- - {onRememberChange} - - -
-
- - {setHost} -
-
- - {onAutoConnectChange} -
-
- -
- ) - }} + {({ handleSubmit, form }) => ( + + )} ); }; -interface LoginFormProps { - onSubmit: any; - disableSubmitButton: boolean, - onResetPassword: any; -} - export default LoginForm; diff --git a/webclient/src/forms/RegisterForm/RegisterForm.tsx b/webclient/src/forms/RegisterForm/RegisterForm.tsx index 66d8916be..3754c857c 100644 --- a/webclient/src/forms/RegisterForm/RegisterForm.tsx +++ b/webclient/src/forms/RegisterForm/RegisterForm.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { Form, Field } from 'react-final-form'; import { OnChange } from 'react-final-form-listeners'; @@ -99,10 +99,11 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
{({ handleSubmit, form }) => { - if (emailRequired) { - // Allow form render to complete - setTimeout(() => form.mutators.setFieldTouched('email', true)) - } + useEffect(() => { + if (emailRequired) { + form.mutators.setFieldTouched('email', true); + } + }, [emailRequired]); return ( <> diff --git a/webclient/src/hooks/__mocks__/useKnownHosts.ts b/webclient/src/hooks/__mocks__/useKnownHosts.ts new file mode 100644 index 000000000..341144939 --- /dev/null +++ b/webclient/src/hooks/__mocks__/useKnownHosts.ts @@ -0,0 +1,40 @@ +import type { HostDTO } from '@app/services'; +import { LoadingState } from '../useSharedStore'; +import type { KnownHostsHook, KnownHostsValue } from '../useKnownHosts'; + +export const makeHost = (overrides: Partial = {}): HostDTO => + ({ + id: 1, + name: 'Test Host', + host: 'test.example', + port: '4747', + editable: false, + lastSelected: true, + userName: undefined, + hashedPassword: undefined, + remember: false, + save: vi.fn(), + ...overrides, + }) as unknown as HostDTO; + +export const makeKnownHostsValue = (overrides: Partial = {}): KnownHostsValue => { + const host = makeHost(); + return { hosts: [host], selectedHost: host, ...overrides }; +}; + +export const makeKnownHostsHook = (overrides: Partial = {}): KnownHostsHook => + ({ + status: LoadingState.READY, + value: makeKnownHostsValue(), + select: vi.fn().mockResolvedValue(undefined), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn().mockResolvedValue(undefined), + ...overrides, + }) as KnownHostsHook; + +export const useKnownHosts = vi.fn<() => KnownHostsHook>(() => makeKnownHostsHook()); + +export const getKnownHosts = vi.fn<() => Promise>(() => + Promise.resolve(makeKnownHostsValue()) +); diff --git a/webclient/src/hooks/__mocks__/useSettings.ts b/webclient/src/hooks/__mocks__/useSettings.ts new file mode 100644 index 000000000..27d8a6c3f --- /dev/null +++ b/webclient/src/hooks/__mocks__/useSettings.ts @@ -0,0 +1,20 @@ +import type { SettingDTO } from '@app/services'; +import { LoadingState } from '../useSharedStore'; +import type { SettingsHook } from '../useSettings'; + +export const makeSettings = (overrides: Partial = {}): SettingDTO => + ({ user: '*app', autoConnect: false, save: vi.fn(), ...overrides }) as SettingDTO; + +export const makeSettingsHook = (overrides: Partial = {}): SettingsHook => + ({ + status: LoadingState.READY, + value: makeSettings(), + update: vi.fn().mockResolvedValue(undefined), + ...overrides, + }) as SettingsHook; + +export const useSettings = vi.fn<() => SettingsHook>(() => makeSettingsHook()); + +export const getSettings = vi.fn<() => Promise>(() => + Promise.resolve(makeSettings()) +); diff --git a/webclient/src/hooks/index.ts b/webclient/src/hooks/index.ts index a9385d50b..679bca669 100644 --- a/webclient/src/hooks/index.ts +++ b/webclient/src/hooks/index.ts @@ -1,6 +1,8 @@ -export * from './useAutoConnect'; +export * from './useAutoLogin'; export * from './useFireOnce'; -export * from './useDebounce'; +export * from './useKnownHosts'; export * from './useLocaleSort'; export * from './useReduxEffect'; +export * from './useSettings'; +export * from './useSharedStore'; export * from './useWebClient'; diff --git a/webclient/src/hooks/useAutoConnect.ts b/webclient/src/hooks/useAutoConnect.ts deleted file mode 100644 index f8daa78aa..000000000 --- a/webclient/src/hooks/useAutoConnect.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { SettingDTO } from '@app/services'; -import { App } from '@app/types'; - -export function useAutoConnect() { - const [setting, setSetting] = useState(undefined); - const [autoConnect, setAutoConnect] = useState(undefined); - - useEffect(() => { - SettingDTO.get(App.APP_USER).then((setting: SettingDTO) => { - if (!setting) { - setting = new SettingDTO(App.APP_USER); - setting.save(); - } - - setSetting(setting); - }); - }, []); - - useEffect(() => { - if (setting) { - setAutoConnect(setting.autoConnect); - } - }, [setting]); - - useEffect(() => { - if (setting) { - setting.autoConnect = autoConnect; - setting.save(); - } - }, [setting, autoConnect]); - - return [autoConnect, setAutoConnect]; -} diff --git a/webclient/src/hooks/useAutoLogin.spec.tsx b/webclient/src/hooks/useAutoLogin.spec.tsx new file mode 100644 index 000000000..b825bb370 --- /dev/null +++ b/webclient/src/hooks/useAutoLogin.spec.tsx @@ -0,0 +1,155 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +vi.mock('./useSettings'); +vi.mock('./useKnownHosts'); + +type AnyRecord = Record; + +let useAutoLoginModule: typeof import('./useAutoLogin'); +let getSettingsMock: any; +let getKnownHostsMock: any; +let makeSettings: (o?: AnyRecord) => AnyRecord; +let makeHost: (o?: AnyRecord) => AnyRecord; + +beforeEach(async () => { + // Fresh module graph per test so autoLoginGate.hasChecked resets. + vi.resetModules(); + useAutoLoginModule = await import('./useAutoLogin'); + const settingsMockModule = await import('./__mocks__/useSettings'); + const hostsMockModule = await import('./__mocks__/useKnownHosts'); + getSettingsMock = settingsMockModule.getSettings; + getKnownHostsMock = hostsMockModule.getKnownHosts; + makeSettings = settingsMockModule.makeSettings as any; + makeHost = hostsMockModule.makeHost as any; +}); + +interface ConfigureOptions { + autoConnect?: boolean; + remember?: boolean; + hashedPassword?: string; + userName?: string; +} + +const configure = ({ + autoConnect = false, + remember = false, + hashedPassword = undefined, + userName = 'joe', +}: ConfigureOptions) => { + const settings = makeSettings({ autoConnect }); + const host = makeHost({ remember, hashedPassword, userName, lastSelected: true }); + + getSettingsMock.mockResolvedValue(settings); + getKnownHostsMock.mockResolvedValue({ hosts: [host], selectedHost: host }); +}; + +describe('useAutoLogin', () => { + test('fires onLogin when all conditions are met', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: true, hashedPassword: 'hp', userName: 'joe' }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1)); + expect(onLogin.mock.calls[0][0]).toMatchObject({ + userName: 'joe', + remember: true, + password: '', + }); + }); + + test('does not fire when settings.autoConnect is false', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + // Let the pending promise flush. + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('does not fire when host lacks remember flag', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: false, hashedPassword: 'hp' }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('does not fire when host lacks hashedPassword', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: true, hashedPassword: undefined }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('does not fire when a connection attempt is already in flight', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, true)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('fires at most once per session, even across unmount + remount', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + + const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1)); + + unmount(); + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).toHaveBeenCalledTimes(1); + }); + + test('manual login then logout does NOT auto-connect on return to /login', async () => { + const onLogin = vi.fn(); + + configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); + const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + + unmount(); + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('ticking the auto-connect checkbox after mount does NOT trigger a login', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); + + const { rerender } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + rerender(); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/hooks/useAutoLogin.ts b/webclient/src/hooks/useAutoLogin.ts new file mode 100644 index 000000000..4e8cf0770 --- /dev/null +++ b/webclient/src/hooks/useAutoLogin.ts @@ -0,0 +1,58 @@ +import { useEffect } from 'react'; + +import type { HostDTO } from '@app/services'; + +import { getKnownHosts } from './useKnownHosts'; +import { getSettings } from './useSettings'; + +export interface LoginFormValues { + userName: string; + password?: string; + selectedHost: HostDTO; + remember: boolean; + autoConnect?: boolean; +} + +export const autoLoginGate = { hasChecked: false }; + +export function useAutoLogin( + onLogin: (values: LoginFormValues) => void, + connectionAttemptMade: boolean, +): void { + useEffect(() => { + if (autoLoginGate.hasChecked) { + return; + } + if (connectionAttemptMade) { + return; + } + + let cancelled = false; + + Promise.all([getSettings(), getKnownHosts()]).then(([settings, hosts]) => { + if (cancelled || autoLoginGate.hasChecked) { + return; + } + autoLoginGate.hasChecked = true; + + if (!settings.autoConnect) { + return; + } + const { selectedHost } = hosts; + if (!selectedHost?.remember || !selectedHost?.hashedPassword) { + return; + } + + onLogin({ + selectedHost, + userName: selectedHost.userName ?? '', + remember: true, + password: '', + }); + }); + + return () => { + cancelled = true; + }; + }, [connectionAttemptMade, onLogin]); +} diff --git a/webclient/src/hooks/useDebounce.ts b/webclient/src/hooks/useDebounce.ts deleted file mode 100644 index f8b62e565..000000000 --- a/webclient/src/hooks/useDebounce.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useCallback } from 'react'; - -type UseDebounceType = (...args: any) => any; -const DEBOUNCE_DELAY = 250; - -export interface DebouncedFn { - (...args: Parameters): void; - cancel(): void; -} - -function debounce(fn: T, timeout: number): DebouncedFn { - let timer: ReturnType | undefined; - const debounced = ((...args: Parameters): void => { - if (timer !== undefined) { - clearTimeout(timer); - } - timer = setTimeout(() => fn(...args), timeout); - }) as DebouncedFn; - debounced.cancel = (): void => { - if (timer !== undefined) { - clearTimeout(timer); - timer = undefined; - } - }; - return debounced; -} - -export function useDebounce( - fn: T, - deps: any[] = [], - timeout: number = DEBOUNCE_DELAY -): DebouncedFn { - return useCallback(debounce(fn, timeout), deps); -} diff --git a/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx b/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx index 9c87ccf64..da80c876e 100644 --- a/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx +++ b/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx @@ -2,12 +2,13 @@ import { render, fireEvent, waitFor, + renderHook, + act, } from '@testing-library/react'; import { useFireOnce } from './useFireOnce'; describe('useFireOnce hook', () => { test('it only fires once when button is clicked twice', async () => { - // Mock a promise with a delay const onClickWithPromise = vi.fn((e) => { e.preventDefault() return new Promise((resolve) => { @@ -23,25 +24,20 @@ describe('useFireOnce hook', () => { return } - // render the button const { getByRole } = render( ); - //Grab the button from the DOM and confirm it initialized in an enabled state const button = getByRole('button', { name: 'Click Me!' }); expect(button).toBeEnabled(); - // Simulate two click events in a row fireEvent.click(button); fireEvent.click(button); - // Confirm that it's disabled await waitFor(() => { expect(button).toBeDisabled(); }); - // Confirm it became enabled after the timeout and that the click event was only fired once await waitFor( () => { expect(onClickWithPromise).toHaveBeenCalledTimes(1); @@ -51,7 +47,6 @@ describe('useFireOnce hook', () => { }); test('it only fires once when form is submitted twice', async () => { - // Mock a promise with a delay const onClickWithPromise = vi.fn((e) => { e.preventDefault() return new Promise((resolve) => { @@ -72,25 +67,20 @@ describe('useFireOnce hook', () => { ) } - // render the form const { getByRole } = render( ); - //Grab the button from the DOM and confirm it initialized in an enabled state const button = getByRole('button', { name: 'Click Me!' }); expect(button).toBeEnabled(); - // Simulate two click events in a row fireEvent.click(button); fireEvent.click(button); - // Confirm that it's disabled await waitFor(() => { expect(button).toBeDisabled(); }); - // Confirm it became enabled after the timeout and that the click event was only fired once await waitFor( () => { expect(onClickWithPromise).toHaveBeenCalledTimes(1); @@ -98,4 +88,56 @@ describe('useFireOnce hook', () => { { timeout: 100 } ); }); + + test('resetInFlightStatus re-enables firing', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useFireOnce(fn)); + + act(() => { + result.current[2](); + }); + + expect(result.current[0]).toBe(true); + expect(fn).toHaveBeenCalledTimes(1); + + act(() => { + result.current[1](); + }); + + expect(result.current[0]).toBe(false); + + act(() => { + result.current[2](); + }); + + expect(fn).toHaveBeenCalledTimes(2); + }); + + test('calls the latest fn when parent updates it', () => { + const fn1 = vi.fn(); + const fn2 = vi.fn(); + const { result, rerender } = renderHook(({ fn }) => useFireOnce(fn), { + initialProps: { fn: fn1 }, + }); + + rerender({ fn: fn2 }); + + act(() => { + result.current[2](); + }); + + expect(fn1).not.toHaveBeenCalled(); + expect(fn2).toHaveBeenCalledTimes(1); + }); + + test('passes all arguments through to fn', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useFireOnce(fn)); + + act(() => { + result.current[2]('a', 'b', 'c'); + }); + + expect(fn).toHaveBeenCalledWith('a', 'b', 'c'); + }); }); diff --git a/webclient/src/hooks/useFireOnce/useFireOnce.ts b/webclient/src/hooks/useFireOnce/useFireOnce.ts index 54c184160..e4f5265de 100644 --- a/webclient/src/hooks/useFireOnce/useFireOnce.ts +++ b/webclient/src/hooks/useFireOnce/useFireOnce.ts @@ -1,15 +1,20 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; type UseFireOnceType = (...args: any) => any; -export function useFireOnce(fn: T): [boolean, any, any] { - const [actionIsInFlight, setActionIsInFlight] = useState(false) - const handleFireOnce = useCallback((args) => { +export function useFireOnce(fn: T): [boolean, () => void, (...args: Parameters) => void] { + const [actionIsInFlight, setActionIsInFlight] = useState(false); + const fnRef = useRef(fn); + fnRef.current = fn; + + const handleFireOnce = useCallback((...args: Parameters) => { setActionIsInFlight(true); - fn(args); - }, []) - function resetInFlightStatus() { + fnRef.current(...args); + }, []); + + const resetInFlightStatus = useCallback(() => { setActionIsInFlight(false); - } - return [actionIsInFlight, resetInFlightStatus, handleFireOnce] + }, []); + + return [actionIsInFlight, resetInFlightStatus, handleFireOnce]; } diff --git a/webclient/src/hooks/useKnownHosts.spec.ts b/webclient/src/hooks/useKnownHosts.spec.ts new file mode 100644 index 000000000..40de6a8af --- /dev/null +++ b/webclient/src/hooks/useKnownHosts.spec.ts @@ -0,0 +1,211 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; + +type StoredHost = { + id?: number; + name: string; + host: string; + port: string; + editable: boolean; + lastSelected?: boolean; + userName?: string; + hashedPassword?: string; + remember?: boolean; + save?: ReturnType; +}; + +let stored: StoredHost[] = []; +let nextId = 1; + +async function upsertStoredHost(this: StoredHost) { + const idx = stored.findIndex((h) => h.id === this.id); + if (idx >= 0) { + stored[idx] = this; + } else { + this.id = this.id ?? nextId++; + stored.push(this); + } +} + +const mockSave = vi.fn<(self: StoredHost) => Promise>(upsertStoredHost); + +vi.mock('@app/services', () => ({ + HostDTO: class MockHostDTO { + id?: number; + name!: string; + host!: string; + port!: string; + editable!: boolean; + lastSelected?: boolean; + userName?: string; + hashedPassword?: string; + remember?: boolean; + + save = function save(this: StoredHost) { + return mockSave.call(this); + }; + + static getAll = vi.fn(async () => { + return stored.map((h) => { + const inst = new MockHostDTO() as unknown as StoredHost; + Object.assign(inst, h); + (inst as unknown as MockHostDTO).save = function save() { + return mockSave.call(this as unknown as StoredHost); + }; + return inst; + }); + }); + + static get = vi.fn(async (id: number) => { + const match = stored.find((h) => h.id === id); + if (!match) { + return undefined; + } + const inst = new MockHostDTO() as unknown as StoredHost; + Object.assign(inst, match); + (inst as unknown as MockHostDTO).save = function save() { + return mockSave.call(this as unknown as StoredHost); + }; + return inst; + }); + + static add = vi.fn(async (host: StoredHost) => { + const id = nextId++; + stored.push({ ...host, id }); + return id; + }); + + static bulkAdd = vi.fn(async (hosts: StoredHost[]) => { + for (const h of hosts) { + stored.push({ ...h, id: nextId++ }); + } + }); + + static delete = vi.fn(async (id: number | string) => { + const numericId = typeof id === 'string' ? Number(id) : id; + stored = stored.filter((h) => h.id !== numericId); + }); + }, + DefaultHosts: [ + { name: 'A', host: 'a.x', port: '1', editable: false }, + { name: 'B', host: 'b.x', port: '2', editable: false }, + ], +})); + +vi.mock('@app/types', () => ({ App: {} })); + +let useKnownHostsModule: typeof import('./useKnownHosts'); +let LoadingState: typeof import('./useSharedStore').LoadingState; + +beforeEach(async () => { + vi.resetModules(); + stored = []; + nextId = 1; + mockSave.mockClear(); + useKnownHostsModule = await import('./useKnownHosts'); + ({ LoadingState } = await import('./useSharedStore')); +}); + +describe('useKnownHosts', () => { + test('seeds DefaultHosts when the DB is empty and pins hosts[0] as lastSelected', async () => { + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.hosts).toHaveLength(2); + expect(result.current.value.selectedHost.name).toBe('A'); + expect(result.current.value.selectedHost.lastSelected).toBe(true); + }); + + test('select(id) flips lastSelected atomically — exactly one row true', async () => { + stored = [ + { id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true }, + { id: 2, name: 'B', host: 'b', port: '2', editable: false, lastSelected: false }, + ]; + nextId = 3; + + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + await act(async () => { + await result.current.select(2); + }); + + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.selectedHost.id).toBe(2); + const lastSelectedCount = result.current.value.hosts.filter((h) => h.lastSelected).length; + expect(lastSelectedCount).toBe(1); + }); + + test('add() persists to Dexie and mirrors the new host into in-memory state', async () => { + stored = [{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true }]; + nextId = 2; + + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + await waitFor(() => expect(result.current.status).toBe(LoadingState.READY)); + + await act(async () => { + await result.current.add({ name: 'C', host: 'c', port: '3', editable: true }); + }); + + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.hosts).toHaveLength(2); + expect(result.current.value.hosts.some((h) => h.name === 'C')).toBe(true); + }); + + test('update() patches the host and replaces the snapshot reference', async () => { + stored = [ + { id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true, remember: false }, + ]; + nextId = 2; + + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + await waitFor(() => expect(result.current.status).toBe(LoadingState.READY)); + + const before = result.current; + + await act(async () => { + await result.current.update(1, { remember: true, userName: 'joe' }); + }); + + expect(result.current).not.toBe(before); + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.hosts[0].remember).toBe(true); + expect(result.current.value.hosts[0].userName).toBe('joe'); + }); + + test('remove() deletes and picks a new selectedHost when the removed row was selected', async () => { + stored = [ + { id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true }, + { id: 2, name: 'B', host: 'b', port: '2', editable: false, lastSelected: false }, + ]; + nextId = 3; + + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + await waitFor(() => expect(result.current.status).toBe(LoadingState.READY)); + + await act(async () => { + await result.current.remove(1); + }); + + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.hosts).toHaveLength(1); + expect(result.current.value.selectedHost.id).toBe(2); + expect(result.current.value.selectedHost.lastSelected).toBe(true); + }); +}); diff --git a/webclient/src/hooks/useKnownHosts.ts b/webclient/src/hooks/useKnownHosts.ts new file mode 100644 index 000000000..d3ebf0c29 --- /dev/null +++ b/webclient/src/hooks/useKnownHosts.ts @@ -0,0 +1,119 @@ +import { DefaultHosts, HostDTO } from '@app/services'; +import { App } from '@app/types'; + +import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; + +export interface KnownHostsValue { + hosts: HostDTO[]; + selectedHost: HostDTO; +} + +const loadAll = async (): Promise => { + let hosts = await HostDTO.getAll(); + if (!hosts?.length) { + await HostDTO.bulkAdd(DefaultHosts); + hosts = await HostDTO.getAll(); + } + return hosts; +}; + +const normalize = async (hosts: HostDTO[]): Promise => { + const existing = hosts.find((h) => h.lastSelected); + if (existing) { + return { hosts, selectedHost: existing }; + } + + const selected = hosts[0]; + selected.lastSelected = true; + await selected.save(); + return { hosts, selectedHost: selected }; +}; + +export const knownHostsStore = createSharedStore(async () => { + const hosts = await loadAll(); + return normalize(hosts); +}); +const store = knownHostsStore; + +export type KnownHostsHook = Loadable & { + select: (id: number) => Promise; + add: (host: App.Host) => Promise; + update: (id: number, patch: Partial) => Promise; + remove: (id: number) => Promise; +}; + +const requireValue = (method: string): KnownHostsValue => { + const current = store.peek(); + if (!current) { + throw new Error(`useKnownHosts.${method} called before hosts loaded`); + } + return current; +}; + +const select = async (id: number): Promise => { + const { hosts } = requireValue('select'); + const target = hosts.find((h) => h.id === id); + if (!target) { + throw new Error(`useKnownHosts.select: unknown host id ${id}`); + } + + const writes: Promise[] = []; + for (const h of hosts) { + if (h === target) { + if (!h.lastSelected) { + h.lastSelected = true; + writes.push(h.save()); + } + } else if (h.lastSelected) { + h.lastSelected = false; + writes.push(h.save()); + } + } + await Promise.all(writes); + + store.setValue({ hosts: [...hosts], selectedHost: target }); +}; + +const add = async (host: App.Host): Promise => { + const { hosts, selectedHost } = requireValue('add'); + const created = await HostDTO.get((await HostDTO.add(host)) as number); + store.setValue({ hosts: [...hosts, created], selectedHost }); + return created; +}; + +const update = async (id: number, patch: Partial): Promise => { + const { hosts, selectedHost } = requireValue('update'); + const existing = hosts.find((h) => h.id === id); + if (!existing) { + throw new Error(`useKnownHosts.update: unknown host id ${id}`); + } + Object.assign(existing, patch); + await existing.save(); + store.setValue({ + hosts: [...hosts], + selectedHost: selectedHost.id === id ? existing : selectedHost, + }); + return existing; +}; + +const remove = async (id: number): Promise => { + const { hosts, selectedHost } = requireValue('remove'); + await HostDTO.delete(id as unknown as string); + const next = hosts.filter((h) => h.id !== id); + let nextSelected = selectedHost; + if (selectedHost.id === id) { + nextSelected = next[0]; + if (nextSelected) { + nextSelected.lastSelected = true; + await nextSelected.save(); + } + } + store.setValue({ hosts: next, selectedHost: nextSelected }); +}; + +export function useKnownHosts(): KnownHostsHook { + const state = useSharedStore(store); + return { ...state, select, add, update, remove }; +} + +export const getKnownHosts = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useLocaleSort.spec.ts b/webclient/src/hooks/useLocaleSort.spec.ts new file mode 100644 index 000000000..f13bdea46 --- /dev/null +++ b/webclient/src/hooks/useLocaleSort.spec.ts @@ -0,0 +1,80 @@ +import { renderHook } from '@testing-library/react'; +import { useLocaleSort } from './useLocaleSort'; + +let mockLanguage = 'en'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + i18n: { + get language() { + return mockLanguage; + }, + }, + }), +})); + +describe('useLocaleSort', () => { + beforeEach(() => { + mockLanguage = 'en'; + }); + + test('sorts strings by locale using the valueGetter', () => { + const arr = ['c', 'a', 'b']; + const { result } = renderHook(() => useLocaleSort(arr, (v) => v)); + + expect(result.current).toEqual(['a', 'b', 'c']); + }); + + test('sorts using valueGetter to resolve display values', () => { + const lookup: Record = { x: 'cherry', y: 'apple', z: 'banana' }; + const arr = ['x', 'y', 'z']; + const { result } = renderHook(() => useLocaleSort(arr, (v) => lookup[v])); + + expect(result.current).toEqual(['y', 'z', 'x']); + }); + + test('handles empty array', () => { + const { result } = renderHook(() => useLocaleSort([], (v) => v)); + + expect(result.current).toEqual([]); + }); + + test('does not mutate the input array', () => { + const arr = ['c', 'a', 'b']; + renderHook(() => useLocaleSort(arr, (v) => v)); + + expect(arr).toEqual(['c', 'a', 'b']); + }); + + test('updates when arr prop changes', () => { + let arr = ['c', 'a']; + const getter = (v: string) => v; + const { result, rerender } = renderHook(() => useLocaleSort(arr, getter)); + + expect(result.current).toEqual(['a', 'c']); + + arr = ['z', 'b', 'a']; + rerender(); + + expect(result.current).toEqual(['a', 'b', 'z']); + }); + + test('re-sorts when language changes', () => { + // Swedish sorts ä after z; English sorts ä near a + const arr = ['ä', 'b', 'z']; + const getter = (v: string) => v; + + mockLanguage = 'en'; + const { result, rerender } = renderHook(() => useLocaleSort(arr, getter)); + const englishOrder = [...result.current]; + + mockLanguage = 'sv'; + rerender(); + const swedishOrder = [...result.current]; + + // In Swedish, ä comes after z + expect(swedishOrder[swedishOrder.length - 1]).toBe('ä'); + // In English, ä sorts near 'a', before 'b' + expect(englishOrder.indexOf('ä')).toBeLessThan(englishOrder.indexOf('b')); + }); +}); diff --git a/webclient/src/hooks/useLocaleSort.ts b/webclient/src/hooks/useLocaleSort.ts index 219292ed7..0b463a34f 100644 --- a/webclient/src/hooks/useLocaleSort.ts +++ b/webclient/src/hooks/useLocaleSort.ts @@ -1,18 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -export function useLocaleSort(arr: string[], valueGetter: (value: string) => string) { - const [state] = useState(arr); - const [sorted, setSorted] = useState([]); - +export function useLocaleSort(arr: string[], valueGetter: (value: string) => string): string[] { const { i18n } = useTranslation(); - useEffect(() => { + return useMemo(() => { const collator = new Intl.Collator(i18n.language); - const sorter = (a, b) => collator.compare(valueGetter(a), valueGetter(b)); - - setSorted(state.sort(sorter)); - }, [state, i18n.language]); - - return sorted; + return [...arr].sort((a, b) => collator.compare(valueGetter(a), valueGetter(b))); + }, [arr, i18n.language, valueGetter]); } diff --git a/webclient/src/hooks/useReduxEffect.spec.tsx b/webclient/src/hooks/useReduxEffect.spec.tsx new file mode 100644 index 000000000..8b71d63ea --- /dev/null +++ b/webclient/src/hooks/useReduxEffect.spec.tsx @@ -0,0 +1,138 @@ +import { renderHook, act } from '@testing-library/react'; +import { configureStore, combineReducers } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import { StrictMode, ReactNode } from 'react'; +import { useReduxEffect } from './useReduxEffect'; +import { actionReducer } from '../store/actions/actionReducer'; + +function makeStore() { + return configureStore({ + reducer: combineReducers({ action: actionReducer }), + }); +} + +function makeWrapper(store: ReturnType) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useReduxEffect', () => { + test('fires callback when matching action type is dispatched', () => { + const store = makeStore(); + const effect = vi.fn(); + + renderHook(() => useReduxEffect(effect, 'TEST_ACTION'), { + wrapper: makeWrapper(store), + }); + + act(() => { + store.dispatch({ type: 'TEST_ACTION', payload: 'hello' }); + }); + + expect(effect).toHaveBeenCalledTimes(1); + expect(effect).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TEST_ACTION' }), + ); + }); + + test('does not fire for non-matching action types', () => { + const store = makeStore(); + const effect = vi.fn(); + + renderHook(() => useReduxEffect(effect, 'LISTEN_FOR'), { + wrapper: makeWrapper(store), + }); + + act(() => { + store.dispatch({ type: 'OTHER_ACTION' }); + }); + + expect(effect).not.toHaveBeenCalled(); + }); + + test('handles array of action types', () => { + const store = makeStore(); + const effect = vi.fn(); + + renderHook(() => useReduxEffect(effect, ['TYPE_A', 'TYPE_B']), { + wrapper: makeWrapper(store), + }); + + act(() => { + store.dispatch({ type: 'TYPE_A' }); + }); + act(() => { + store.dispatch({ type: 'TYPE_B' }); + }); + act(() => { + store.dispatch({ type: 'TYPE_C' }); + }); + + expect(effect).toHaveBeenCalledTimes(2); + }); + + test('does not double-fire in StrictMode', () => { + const store = makeStore(); + const effect = vi.fn(); + + function StrictWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + + renderHook(() => useReduxEffect(effect, 'TEST'), { + wrapper: StrictWrapper, + }); + + act(() => { + store.dispatch({ type: 'TEST' }); + }); + + expect(effect).toHaveBeenCalledTimes(1); + }); + + test('catches action dispatched before mount via sync check', () => { + const store = makeStore(); + const effect = vi.fn(); + + // Dispatch before the hook mounts + store.dispatch({ type: 'EARLY_ACTION' }); + + renderHook(() => useReduxEffect(effect, 'EARLY_ACTION'), { + wrapper: makeWrapper(store), + }); + + expect(effect).toHaveBeenCalledTimes(1); + expect(effect).toHaveBeenCalledWith( + expect.objectContaining({ type: 'EARLY_ACTION' }), + ); + }); + + test('uses latest effect callback via ref', () => { + const store = makeStore(); + const effect1 = vi.fn(); + const effect2 = vi.fn(); + + const { rerender } = renderHook( + ({ cb }) => useReduxEffect(cb, 'TEST'), + { + wrapper: makeWrapper(store), + initialProps: { cb: effect1 }, + }, + ); + + rerender({ cb: effect2 }); + + act(() => { + store.dispatch({ type: 'TEST' }); + }); + + expect(effect1).not.toHaveBeenCalled(); + expect(effect2).toHaveBeenCalledTimes(1); + }); + +}); diff --git a/webclient/src/hooks/useSettings.spec.ts b/webclient/src/hooks/useSettings.spec.ts new file mode 100644 index 000000000..6fe9361f8 --- /dev/null +++ b/webclient/src/hooks/useSettings.spec.ts @@ -0,0 +1,98 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; + +const mockSave = vi.fn(); +let storedSetting: any = null; + +vi.mock('@app/services', () => ({ + SettingDTO: class MockSettingDTO { + user: string; + autoConnect = false; + constructor(user: string) { + this.user = user; + } + save = mockSave; + static get = vi.fn(() => Promise.resolve(storedSetting)); + }, +})); + +vi.mock('@app/types', () => ({ + App: { APP_USER: '*app' }, +})); + +// Each spec resets module state so the shared store starts fresh. +let useSettingsModule: typeof import('./useSettings'); +let LoadingState: typeof import('./useSharedStore').LoadingState; + +beforeEach(async () => { + vi.resetModules(); + storedSetting = null; + mockSave.mockClear(); + useSettingsModule = await import('./useSettings'); + ({ LoadingState } = await import('./useSharedStore')); +}); + +describe('useSettings', () => { + test('starts in loading state, then resolves to the stored setting', async () => { + storedSetting = { user: '*app', autoConnect: true, save: mockSave }; + + const { result } = renderHook(() => useSettingsModule.useSettings()); + + expect(result.current.status).toBe(LoadingState.LOADING); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + if (result.current.status === LoadingState.READY) { + expect(result.current.value.autoConnect).toBe(true); + } + }); + + test('creates and saves a new SettingDTO when none exists', async () => { + storedSetting = null; + + const { result } = renderHook(() => useSettingsModule.useSettings()); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + if (result.current.status === LoadingState.READY) { + expect(result.current.value.autoConnect).toBe(false); + } + expect(mockSave).toHaveBeenCalledTimes(1); + }); + + test('update() persists the patch and re-renders with a new snapshot', async () => { + storedSetting = { user: '*app', autoConnect: false, save: mockSave }; + + const { result } = renderHook(() => useSettingsModule.useSettings()); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + mockSave.mockClear(); + const before = result.current; + + await act(async () => { + await result.current.update({ autoConnect: true }); + }); + + expect(result.current).not.toBe(before); + if (result.current.status === LoadingState.READY) { + expect(result.current.value.autoConnect).toBe(true); + } + expect(mockSave).toHaveBeenCalledTimes(1); + }); + + test('does not re-save on the initial load when setting already exists', async () => { + storedSetting = { user: '*app', autoConnect: true, save: mockSave }; + + renderHook(() => useSettingsModule.useSettings()); + + await waitFor(() => { + expect(mockSave).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/hooks/useSettings.ts b/webclient/src/hooks/useSettings.ts new file mode 100644 index 000000000..e4792b833 --- /dev/null +++ b/webclient/src/hooks/useSettings.ts @@ -0,0 +1,36 @@ +import { SettingDTO } from '@app/services'; +import { App } from '@app/types'; + +import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; + +export const settingsStore = createSharedStore(async () => { + let loaded = await SettingDTO.get(App.APP_USER); + if (!loaded) { + loaded = new SettingDTO(App.APP_USER); + await loaded.save(); + } + return loaded; +}); +const store = settingsStore; + +export type SettingsHook = Loadable & { + update: (patch: Partial) => Promise; +}; + +export function useSettings(): SettingsHook { + const state = useSharedStore(store); + + const update = async (patch: Partial) => { + const current = store.peek(); + if (!current) { + throw new Error('useSettings.update called before settings loaded'); + } + Object.assign(current, patch); + await current.save(); + store.setValue(current); + }; + + return { ...state, update }; +} + +export const getSettings = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useSharedStore.spec.ts b/webclient/src/hooks/useSharedStore.spec.ts new file mode 100644 index 000000000..a104e16d8 --- /dev/null +++ b/webclient/src/hooks/useSharedStore.spec.ts @@ -0,0 +1,102 @@ +import { createSharedStore, LoadingState } from './useSharedStore'; + +describe('createSharedStore', () => { + test('starts in LOADING state before any subscriber connects', () => { + const store = createSharedStore(async () => 'value'); + expect(store.getSnapshot()).toEqual({ status: LoadingState.LOADING }); + expect(store.peek()).toBeUndefined(); + }); + + test('triggers load on first subscribe and resolves to READY', async () => { + let loadCalls = 0; + const store = createSharedStore(async () => { + loadCalls++; + return 42; + }); + + const cb = vi.fn(); + store.subscribe(cb); + + await vi.waitFor(() => { + expect(store.getSnapshot()).toEqual({ status: LoadingState.READY, value: 42 }); + }); + + expect(loadCalls).toBe(1); + expect(cb).toHaveBeenCalled(); + expect(store.peek()).toBe(42); + }); + + test('multiple subscribers share a single load', async () => { + let loadCalls = 0; + const store = createSharedStore(async () => { + loadCalls++; + return 'shared'; + }); + + const cb1 = vi.fn(); + const cb2 = vi.fn(); + store.subscribe(cb1); + store.subscribe(cb2); + + await vi.waitFor(() => { + expect(store.getSnapshot()).toEqual({ status: LoadingState.READY, value: 'shared' }); + }); + + expect(loadCalls).toBe(1); + expect(cb1).toHaveBeenCalled(); + expect(cb2).toHaveBeenCalled(); + }); + + test('setValue notifies subscribers with a new snapshot reference', async () => { + const store = createSharedStore(async () => ({ n: 1 })); + const cb = vi.fn(); + store.subscribe(cb); + + await vi.waitFor(() => expect(store.getSnapshot().status).toBe(LoadingState.READY)); + + const before = store.getSnapshot(); + cb.mockClear(); + + store.setValue({ n: 2 }); + + const after = store.getSnapshot(); + expect(after).not.toBe(before); + expect(after).toEqual({ status: LoadingState.READY, value: { n: 2 } }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + test('transitions to ERROR when loader rejects and notifies subscribers', async () => { + const store = createSharedStore(async () => { + throw new Error('boom'); + }); + + const cb = vi.fn(); + store.subscribe(cb); + + await vi.waitFor(() => { + expect(store.getSnapshot().status).toBe(LoadingState.ERROR); + }); + + const snapshot = store.getSnapshot(); + expect(snapshot.status).toBe(LoadingState.ERROR); + if (snapshot.status === LoadingState.ERROR) { + expect(snapshot.error.message).toBe('boom'); + } + expect(cb).toHaveBeenCalled(); + expect(store.peek()).toBeUndefined(); + }); + + test('unsubscribe removes the callback', async () => { + const store = createSharedStore(async () => 'x'); + const cb = vi.fn(); + const unsubscribe = store.subscribe(cb); + + await vi.waitFor(() => expect(store.getSnapshot().status).toBe(LoadingState.READY)); + cb.mockClear(); + + unsubscribe(); + store.setValue('y'); + + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/hooks/useSharedStore.ts b/webclient/src/hooks/useSharedStore.ts new file mode 100644 index 000000000..668545f79 --- /dev/null +++ b/webclient/src/hooks/useSharedStore.ts @@ -0,0 +1,112 @@ +import { useSyncExternalStore } from 'react'; + +export enum LoadingState { + LOADING = 'loading', + READY = 'ready', + ERROR = 'error', +} + +export interface Loadable { + status: LoadingState; + value?: T; + error?: Error; +} + +// @critical Two surfaces: subscribe (reactive) vs whenReady (one-shot). +// See .github/instructions/webclient.instructions.md#shared-store-pattern. +export interface SharedStore { + subscribe: (cb: () => void) => () => void; + getSnapshot: () => Loadable; + whenReady: () => Promise; + setValue: (value: T) => void; + peek: () => T | undefined; + reset: () => void; +} + +export function createSharedStore(load: () => Promise): SharedStore { + let state: Loadable = { status: LoadingState.LOADING }; + const subscribers = new Set<() => void>(); + let loadStarted = false; + + // Lazy to avoid unhandled-rejection bookkeeping when no caller awaits it. + let readyPromise: Promise | null = null; + + const notify = () => { + for (const cb of subscribers) { + cb(); + } + }; + + const ensureLoaded = () => { + if (loadStarted) { + return; + } + loadStarted = true; + load().then( + (value) => { + state = { status: LoadingState.READY, value }; + notify(); + }, + (error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + state = { status: LoadingState.ERROR, error: err }; + notify(); + } + ); + }; + + const subscribe = (cb: () => void) => { + subscribers.add(cb); + ensureLoaded(); + return () => { + subscribers.delete(cb); + }; + }; + + return { + subscribe, + getSnapshot: () => state, + whenReady: () => { + ensureLoaded(); + if (!readyPromise) { + readyPromise = new Promise((resolve, reject) => { + const settle = (): boolean => { + if (state.status === LoadingState.READY) { + resolve(state.value as T); + return true; + } + if (state.status === LoadingState.ERROR && state.error) { + reject(state.error); + return true; + } + return false; + }; + if (settle()) { + return; + } + const unsub = subscribe(() => { + if (settle()) { + unsub(); + } + }); + }); + } + return readyPromise; + }, + setValue: (value) => { + state = { status: LoadingState.READY, value }; + notify(); + }, + peek: () => (state.status === LoadingState.READY ? (state.value as T) : undefined), + reset: () => { + state = { status: LoadingState.LOADING }; + loadStarted = false; + readyPromise = null; + notify(); + }, + }; +} + +export function useSharedStore(store: SharedStore): Loadable { + return useSyncExternalStore(store.subscribe, store.getSnapshot); +} diff --git a/webclient/src/hooks/useWebClient.spec.tsx b/webclient/src/hooks/useWebClient.spec.tsx new file mode 100644 index 000000000..629de8714 --- /dev/null +++ b/webclient/src/hooks/useWebClient.spec.tsx @@ -0,0 +1,48 @@ +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { WebClientProvider, useWebClient } from './useWebClient'; + +vi.mock('@app/websocket', () => ({ + WebClient: class MockWebClient {}, +})); + +vi.mock('@app/api', () => ({ + createWebClientRequest: vi.fn(() => 'request'), + createWebClientResponse: vi.fn(() => 'response'), +})); + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe('useWebClient', () => { + test('provides the WebClient instance to children', () => { + const { result } = renderHook(() => useWebClient(), { wrapper: Wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current.constructor.name).toBe('MockWebClient'); + }); + + test('throws when used outside WebClientProvider', () => { + // Suppress React error boundary console output + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useWebClient()); + }).toThrow('useWebClient must be used within a WebClientProvider'); + + spy.mockRestore(); + }); + + test('returns the same instance across re-renders', () => { + const { result, rerender } = renderHook(() => useWebClient(), { + wrapper: Wrapper, + }); + + const first = result.current; + rerender(); + const second = result.current; + + expect(first).toBe(second); + }); +}); diff --git a/webclient/src/hooks/useWebClient.tsx b/webclient/src/hooks/useWebClient.tsx index 469f03dc9..3d9c02697 100644 --- a/webclient/src/hooks/useWebClient.tsx +++ b/webclient/src/hooks/useWebClient.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useState, ReactNode } from 'react'; import { WebClient } from '@app/websocket'; import { createWebClientRequest, createWebClientResponse } from '@app/api'; -const WebClientContext = createContext(null); +export const WebClientContext = createContext(null); export function WebClientProvider({ children }: { children: ReactNode }) { const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse())); diff --git a/webclient/src/index.tsx b/webclient/src/index.tsx index be51341a4..b0137f943 100644 --- a/webclient/src/index.tsx +++ b/webclient/src/index.tsx @@ -1,5 +1,4 @@ -// MUST be first: installs BigInt.prototype.toJSON before any module that -// creates the Redux store or connects to Redux DevTools. +// @critical Must be the first import. See .github/instructions/webclient.instructions.md#initialization-order. import './polyfills'; import { StrictMode } from 'react'; diff --git a/webclient/src/material-theme.ts b/webclient/src/material-theme.ts index beee1146e..8f646c85a 100644 --- a/webclient/src/material-theme.ts +++ b/webclient/src/material-theme.ts @@ -32,35 +32,9 @@ const palette = { A400: '#303030', A700: '#616161', }, - // secondary: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, - // error: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, - // warning: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, - // info: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, success: { main: '#6CDF39', light: '#6CDF39', - // dark: '', - // contrastText: '', }, }; @@ -201,10 +175,6 @@ export const materialTheme = createTheme({ fontSize: 24, fontWeight: 'bold', }, - // h3: {}, - // h4: {}, - // h5: {}, - // h6: {}, subtitle1: { fontSize: 14, fontWeight: 'bold', @@ -219,10 +189,6 @@ export const materialTheme = createTheme({ fontSize: '.75rem', lineHeight: 1.4, }, - // body2: {}, - // button: {}, - // caption: {}, - // overline: {}, }, spacing: 8, diff --git a/webclient/src/polyfills.ts b/webclient/src/polyfills.ts index d18a0c94c..e38af7101 100644 --- a/webclient/src/polyfills.ts +++ b/webclient/src/polyfills.ts @@ -1,17 +1,5 @@ -// Runtime polyfills that must execute before any other application module. -// Import this file first from `src/index.tsx`. - -// ── BigInt.prototype.toJSON ─────────────────────────────────────────────────── -// Protobuf-ES maps proto `int64`/`uint64` fields to native `BigInt`. Those -// land in Redux state (e.g. `ServerInfo_User.accountageSecs`, -// `Response_Register.deniedEndTime`, the outbound `cmdId`), and any consumer -// that JSON-stringifies state — notably the Redux DevTools browser -// extension, but also logging and error-boundary dumps — throws with -// "Do not know how to serialize a BigInt" because `BigInt.prototype` has no -// `toJSON`. Installing one globally makes `JSON.stringify` coerce -// `BigInt → string` instead of throwing. Coercion is lossy but only affects -// serialized representations; the in-memory Redux state still holds real -// `BigInt`s and every consumer reads them via the generated proto accessors. +// @critical Must be imported before any module that can JSON-stringify Redux state +// (BigInt proto fields throw without toJSON). See .github/instructions/webclient.instructions.md#initialization-order. (BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function bigIntToJSON() { return this.toString(); }; diff --git a/webclient/src/services/CardImporterService.spec.ts b/webclient/src/services/CardImporterService.spec.ts new file mode 100644 index 000000000..4880230ab --- /dev/null +++ b/webclient/src/services/CardImporterService.spec.ts @@ -0,0 +1,261 @@ +import { cardImporterService } from './CardImporterService'; + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +function jsonResponse(body: unknown, contentType = 'application/json') { + return { + ok: true, + headers: new Headers({ 'Content-Type': contentType }), + json: () => Promise.resolve(body), + } as unknown as Response; +} + +function textResponse(body: string, ok = true) { + return { + ok, + headers: new Headers({ 'Content-Type': 'application/xml' }), + text: () => Promise.resolve(body), + } as unknown as Response; +} + +function failedResponse(status = 500) { + return { + ok: false, + status, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: () => Promise.resolve({}), + text: () => Promise.resolve(''), + } as unknown as Response; +} + +// Minimal MTGJSON-shaped fixture +const mtgjsonFixture = { + data: { + SET_B: { + code: 'SET_B', + name: 'Set B', + releaseDate: '2020-06-01', + cards: [ + { name: 'Zebra' }, + { name: 'Alpha' }, + ], + tokens: [{ name: 'Token B' }], + }, + SET_A: { + code: 'SET_A', + name: 'Set A', + releaseDate: '2019-01-01', + cards: [ + { name: 'Alpha' }, + { name: 'Beta' }, + ], + tokens: [{ name: 'Token A' }], + }, + }, +}; + +describe('CardImporterService', () => { + describe('importCards', () => { + it('fetches and parses valid MTGJSON data into sorted cards and sets', async () => { + mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture)); + + const { cards, sets } = await cardImporterService.importCards('http://example.com/cards.json'); + + expect(cards).toHaveLength(3); + expect(cards[0].name).toBe('Alpha'); + expect(cards[1].name).toBe('Beta'); + expect(cards[2].name).toBe('Zebra'); + + expect(sets).toHaveLength(2); + expect(sets[0].name).toBe('Set A'); + expect(sets[1].name).toBe('Set B'); + }); + + it('sorts sets by releaseDate ascending', async () => { + mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture)); + + const { sets } = await cardImporterService.importCards('http://example.com/cards.json'); + + expect(sets[0].code).toBe('SET_A'); + expect(sets[1].code).toBe('SET_B'); + }); + + it('deduplicates cards by name, keeping last occurrence (later set wins)', async () => { + mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture)); + + const { cards } = await cardImporterService.importCards('http://example.com/cards.json'); + + // Alpha appears in both SET_A and SET_B; SET_B is later so its version overwrites + // 3 unique names: Alpha (deduped), Beta (SET_A only), Zebra (SET_B only) + expect(cards).toHaveLength(3); + expect(cards.map(c => c.name)).toEqual(['Alpha', 'Beta', 'Zebra']); + }); + + it('maps set cards and tokens to name arrays', async () => { + mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture)); + + const { sets } = await cardImporterService.importCards('http://example.com/cards.json'); + + expect(sets[0].cards).toEqual(['Alpha', 'Beta']); + expect(sets[0].tokens).toEqual(['Token A']); + }); + + it('rejects when response is not ok', async () => { + mockFetch.mockResolvedValue(failedResponse(404)); + + await expect(cardImporterService.importCards('http://example.com/cards.json')) + .rejects.toThrow('Card import must be in valid MTG JSON format'); + }); + + it('rejects when Content-Type does not contain application/json', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: new Headers({ 'Content-Type': 'text/html' }), + json: () => Promise.resolve({}), + } as unknown as Response); + + await expect(cardImporterService.importCards('http://example.com/cards.json')) + .rejects.toThrow('Card import must be in valid MTG JSON format'); + }); + + it('accepts Content-Type with charset parameter', async () => { + mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture, 'application/json; charset=utf-8')); + + const { cards } = await cardImporterService.importCards('http://example.com/cards.json'); + expect(cards.length).toBeGreaterThan(0); + }); + + it('rejects when JSON structure is invalid (missing data key)', async () => { + mockFetch.mockResolvedValue(jsonResponse({ notData: {} })); + + await expect(cardImporterService.importCards('http://example.com/cards.json')) + .rejects.toThrow('Card import must be in valid MTG JSON format'); + }); + + it('preserves the original error as cause', async () => { + mockFetch.mockResolvedValue(jsonResponse({ notData: {} })); + + try { + await cardImporterService.importCards('http://example.com/cards.json'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).cause).toBeDefined(); + } + }); + }); + + describe('importTokens', () => { + const validXml = ` + + + + + + +`; + + it('fetches and parses valid XML into token objects', async () => { + mockFetch.mockResolvedValue(textResponse(validXml)); + + const tokens = await cardImporterService.importTokens('http://example.com/tokens.xml'); + + expect(tokens).toHaveLength(1); + expect(tokens[0]).toHaveProperty('name'); + }); + + it('parses token attributes correctly', async () => { + mockFetch.mockResolvedValue(textResponse(validXml)); + + const tokens = await cardImporterService.importTokens('http://example.com/tokens.xml'); + + const token = tokens[0] as Record; + expect(token.name.value).toBe('Soldier'); + expect(token.set.value).toBe('M21'); + expect(token.set.picURL).toBe('http://example.com/soldier.png'); + }); + + it('rejects when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, text: () => Promise.resolve('') } as unknown as Response); + + await expect(cardImporterService.importTokens('http://example.com/tokens.xml')) + .rejects.toThrow('Failed to fetch'); + }); + + it('rejects when XML is malformed', async () => { + mockFetch.mockResolvedValue(textResponse('')); + + await expect(cardImporterService.importTokens('http://example.com/tokens.xml')) + .rejects.toThrow('Token import must be in valid MTG XML format'); + }); + + it('returns empty array when XML has no card elements', async () => { + const emptyXml = ''; + mockFetch.mockResolvedValue(textResponse(emptyXml)); + + const tokens = await cardImporterService.importTokens('http://example.com/tokens.xml'); + expect(tokens).toEqual([]); + }); + + it('preserves the original error as cause on parse failure', async () => { + mockFetch.mockResolvedValue(textResponse('')); + + try { + await cardImporterService.importTokens('http://example.com/tokens.xml'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).cause).toBeDefined(); + } + }); + }); + + describe('parseXmlAttributes', () => { + function parseXml(xml: string) { + const dom = new DOMParser().parseFromString(xml, 'application/xml'); + return (cardImporterService as any).parseXmlAttributes(dom.documentElement); + } + + it('parses simple child elements into key-value pairs', () => { + const result = parseXml(''); + expect(result.name.value).toBe('Soldier'); + }); + + it('parses nested elements recursively', () => { + const result = parseXml(''); + expect(result.prop.value).toHaveProperty('cmc'); + expect(result.prop.value.cmc.value).toBe('2'); + }); + + it('includes XML attributes alongside value', () => { + const result = parseXml(''); + expect(result.set.value).toBe('M21'); + expect(result.set.picURL).toBe('http://img.png'); + }); + + it('converts duplicate tag names into an array preserving all values', () => { + const result = parseXml( + '' + ); + expect(Array.isArray(result.related)).toBe(true); + expect(result.related).toHaveLength(2); + expect(result.related[0].value).toBe('Token A'); + expect(result.related[1].value).toBe('Token B'); + }); + + it('appends to existing array for 3+ duplicate tag names', () => { + const result = parseXml( + '' + ); + expect(Array.isArray(result.set)).toBe(true); + expect(result.set).toHaveLength(3); + expect(result.set[0].value).toBe('A'); + expect(result.set[1].value).toBe('B'); + expect(result.set[2].value).toBe('C'); + }); + + it('reads innerHTML as value for leaf elements without children', () => { + const result = parseXml('Some card text'); + expect(result.text.value).toBe('Some card text'); + }); + }); +}); diff --git a/webclient/src/services/CardImporterService.ts b/webclient/src/services/CardImporterService.ts index 27014b57c..20d155214 100644 --- a/webclient/src/services/CardImporterService.ts +++ b/webclient/src/services/CardImporterService.ts @@ -1,12 +1,19 @@ // Fetch and parse card sets +import { App } from '@app/types'; + class CardImporterService { - importCards(url): Promise { + importCards(url: string): Promise<{ cards: App.Card[]; sets: App.Set[] }> { const error = 'Card import must be in valid MTG JSON format'; return fetch(url) .then(response => { - if (response.headers.get('Content-Type') !== 'application/json') { + if (!response.ok) { + throw new Error(error); + } + + const contentType = response.headers.get('Content-Type') ?? ''; + if (!contentType.includes('application/json')) { throw new Error(error); } @@ -34,13 +41,13 @@ class CardImporterService { .map(key => unsortedCards[key]); return { cards, sets }; - } catch { - throw new Error(error); + } catch (err) { + throw new Error(error, { cause: err }); } }); } - importTokens(url): Promise { + importTokens(url: string): Promise[]> { const error = 'Token import must be in valid MTG XML format'; return fetch(url) @@ -56,13 +63,17 @@ class CardImporterService { const parser = new DOMParser(); const dom = parser.parseFromString(xmlString, 'application/xml'); + if (dom.querySelector('parsererror')) { + throw new Error(error); + } + const tokens = Array.from(dom.querySelectorAll('card')).map( (tokenElement) => this.parseXmlAttributes(tokenElement) ); return tokens; - } catch { - throw new Error(error); + } catch (err) { + throw new Error(error, { cause: err }); } }) } @@ -85,12 +96,12 @@ class CardImporterService { }; } - // @TODO: clean this up and normalize what i'm returning + // @TODO clean this up and normalize what i'm returning if (attributes[child.tagName]) { if (Array.isArray(attributes[child.tagName])) { attributes[child.tagName].push(parsedAttributes) } else { - attributes[child.tagName] = [parsedAttributes]; + attributes[child.tagName] = [attributes[child.tagName], parsedAttributes]; } } else { attributes[child.tagName] = parsedAttributes; diff --git a/webclient/src/services/HostService.ts b/webclient/src/services/HostService.ts new file mode 100644 index 000000000..c17c2a3cf --- /dev/null +++ b/webclient/src/services/HostService.ts @@ -0,0 +1,47 @@ +import { App } from '@app/types'; + +export const DefaultHosts: App.Host[] = [ + { + name: 'Chickatrice', + host: 'mtg.chickatrice.net', + port: '443', + localPort: '4748', + editable: false, + }, + { + name: 'Rooster', + host: 'server.cockatrice.us/servatrice', + port: '4748', + localHost: 'server.cockatrice.us', + editable: false, + }, + { + name: 'Rooster Beta', + host: 'beta.cockatrice.us/servatrice', + port: '4748', + localHost: 'beta.cockatrice.us', + editable: false, + }, + { + name: 'Tetrarch', + host: 'mtg.tetrarch.co/servatrice', + port: '443', + editable: false, + }, +]; + +export const getHostPort = (host: App.Host): { host: string, port: string } => { + const isLocal = window.location.hostname === 'localhost'; + + if (!host) { + return { + host: '', + port: '' + }; + } + + return { + host: !isLocal ? host.host : host.localHost || host.host, + port: !isLocal ? host.port : host.localPort || host.port, + }; +}; diff --git a/webclient/src/services/dexie/DexieDTOs/CardDTO.ts b/webclient/src/services/dexie/DexieDTOs/CardDTO.ts index dd43681ea..863c5107a 100644 --- a/webclient/src/services/dexie/DexieDTOs/CardDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/CardDTO.ts @@ -1,3 +1,4 @@ +import { IndexableType } from 'dexie'; import { App } from '@app/types'; import { dexieService } from '../DexieService'; @@ -7,11 +8,11 @@ export class CardDTO extends App.Card { return dexieService.cards.put(this); } - static get(name) { + static get(name: string) { return dexieService.cards.where('name').equalsIgnoreCase(name).first(); } - static bulkAdd(cards: CardDTO[]): Promise { + static bulkAdd(cards: CardDTO[]): Promise { return dexieService.cards.bulkPut(cards); } }; diff --git a/webclient/src/services/dexie/DexieDTOs/SetDTO.ts b/webclient/src/services/dexie/DexieDTOs/SetDTO.ts index 3bd02903c..ac90e19b7 100644 --- a/webclient/src/services/dexie/DexieDTOs/SetDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/SetDTO.ts @@ -1,3 +1,4 @@ +import { IndexableType } from 'dexie'; import { App } from '@app/types'; import { dexieService } from '../DexieService'; @@ -7,11 +8,11 @@ export class SetDTO extends App.Set { return dexieService.sets.put(this); } - static get(name) { + static get(name: string) { return dexieService.sets.where('name').equalsIgnoreCase(name).first(); } - static bulkAdd(sets: SetDTO[]): Promise { + static bulkAdd(sets: SetDTO[]): Promise { return dexieService.sets.bulkPut(sets); } }; diff --git a/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts b/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts index 357ebc5a7..451d34819 100644 --- a/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts @@ -3,7 +3,7 @@ import { App } from '@app/types'; import { dexieService } from '../DexieService'; export class SettingDTO extends App.Setting { - constructor(user) { + constructor(user: string) { super(); this.user = user; @@ -14,7 +14,7 @@ export class SettingDTO extends App.Setting { return dexieService.settings.put(this); } - static get(user) { + static get(user: string) { return dexieService.settings.where('user').equalsIgnoreCase(user).first(); } }; diff --git a/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts b/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts index 04199e63d..1321fc437 100644 --- a/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts @@ -1,3 +1,4 @@ +import { IndexableType } from 'dexie'; import { App } from '@app/types'; import { dexieService } from '../DexieService'; @@ -7,11 +8,11 @@ export class TokenDTO extends App.Token { return dexieService.tokens.put(this); } - static get(name) { + static get(name: string) { return dexieService.tokens.where('name.value').equalsIgnoreCase(name).first(); } - static bulkAdd(tokens: TokenDTO[]): Promise { + static bulkAdd(tokens: TokenDTO[]): Promise { return dexieService.tokens.bulkPut(tokens); } }; diff --git a/webclient/src/services/dexie/DexieSchemas/v1.schema.ts b/webclient/src/services/dexie/DexieSchemas/v1.schema.ts index 18e7f35c4..32c29d2ed 100644 --- a/webclient/src/services/dexie/DexieSchemas/v1.schema.ts +++ b/webclient/src/services/dexie/DexieSchemas/v1.schema.ts @@ -1,3 +1,5 @@ +import Dexie from 'dexie'; + export enum Stores { SETTINGS = 'settings', CARDS = 'cards', @@ -6,7 +8,7 @@ export enum Stores { HOSTS = 'hosts', } -export const schemaV1 = (db) => { +export const schemaV1 = (db: Dexie) => { db.version(1).stores({ [Stores.CARDS]: 'name', [Stores.SETS]: 'code', diff --git a/webclient/src/services/index.ts b/webclient/src/services/index.ts index ebebeb65e..2d36a2628 100644 --- a/webclient/src/services/index.ts +++ b/webclient/src/services/index.ts @@ -1,3 +1,4 @@ export * from './CardImporterService'; +export * from './HostService'; export * from './ServerProps'; export * from './dexie'; diff --git a/webclient/src/setupTests.ts b/webclient/src/setupTests.ts index 5c422f120..645094434 100644 --- a/webclient/src/setupTests.ts +++ b/webclient/src/setupTests.ts @@ -1,62 +1,48 @@ -// Install runtime polyfills (BigInt.prototype.toJSON) before any module -// under test loads — matches the production boot order in src/index.tsx. +// @critical Must match the production boot order in src/index.tsx. See .github/instructions/webclient.instructions.md#initialization-order. import './polyfills'; -// Ensure jest-dom matchers are available in every test file. import '@testing-library/jest-dom/vitest'; -// ── Global mock hygiene under `isolate: false` ──────────────────────────────── -// -// Vitest is configured with `test.isolate: false` for speed — every spec file -// in a worker shares the same module graph and the same `vi.mock` factories. -// Without aggressive per-test cleanup, state leaks trivially between tests: -// -// - A test accumulates `.mock.calls` on a shared `vi.fn()`. Later tests -// either see stale history or accidentally match on prior invocations. -// - A test installs `vi.spyOn` on a real method. Without restore, the spy -// persists into every following test and file. -// - A test swaps to fake timers. Real-time code in later tests hangs. -// -// `vi.clearAllMocks()` clears `.mock.calls` on every tracked mock without -// touching implementations — safe for module factories that produce `vi.fn()` -// instances at the top of a spec file and rely on those instances sticking -// around. `vi.restoreAllMocks()` restores original implementations on -// `vi.spyOn` targets. `vi.useRealTimers()` drops any fake-timer installation. -// -// NOTE: we intentionally do NOT call `vi.resetAllMocks()` — it resets the -// implementations of `vi.fn()` instances created inside `vi.mock(...)` -// factories, which breaks any spec that expects those mocks to persist -// across tests in the same file (e.g. `store.dispatch` mocked once at file -// load). -// -// If a specific test needs to install its own `mockReturnValue` / -// `mockImplementation`, it should set it in that test's body and rely on -// the next test overwriting or the global `clearAllMocks` clearing calls — -// it should NOT assume the mock is reset to its factory default automatically. -// -// Global snapshot/restore guards for non-`vi.spyOn` globals that tests mutate -// directly. `vi.restoreAllMocks()` only restores `vi.spyOn` targets, so bare -// `Object.defineProperty` writes on `window.location` and `globalThis.WebSocket` -// reassignments leak between tests unless we explicitly capture and restore them. -let _locationDescriptor: PropertyDescriptor | undefined; -let _originalWebSocket: typeof globalThis.WebSocket | undefined; +// jsdom doesn't provide ResizeObserver; react-window needs it. +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as any; +} -beforeEach(() => { - _locationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); - _originalWebSocket = globalThis.WebSocket; +// Dexie eagerly opens IndexedDB on import; jsdom's fake-indexeddb is memory-intensive. +vi.mock('dexie', () => { + const fakeTable = { + mapToClass: () => {}, + get: () => Promise.resolve(null), + put: () => Promise.resolve(), + add: () => Promise.resolve(1), + bulkAdd: () => Promise.resolve(), + delete: () => Promise.resolve(), + toArray: () => Promise.resolve([]), + where: () => ({ equals: () => ({ first: () => Promise.resolve(null) }) }), + }; + class FakeDexie { + version() { + return { stores: () => this }; + } + open() { + return Promise.resolve(this); + } + table() { + return fakeTable; + } + } + return { default: FakeDexie, __esModule: true }; }); +// Tests within a file share the module graph (vite.config.ts sets isolate: true +// between files, not within them). Never add vi.resetAllMocks() — it resets +// vi.fn() instances created inside vi.mock(...) factories at file load. afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); vi.useRealTimers(); - - const currentLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); - if (currentLocationDescriptor !== _locationDescriptor && _locationDescriptor) { - Object.defineProperty(window, 'location', _locationDescriptor); - } - - if (globalThis.WebSocket !== _originalWebSocket) { - globalThis.WebSocket = _originalWebSocket as typeof globalThis.WebSocket; - } }); diff --git a/webclient/src/store/actions/actionReducer.spec.ts b/webclient/src/store/actions/actionReducer.spec.ts index 351995dc5..5808d8b53 100644 --- a/webclient/src/store/actions/actionReducer.spec.ts +++ b/webclient/src/store/actions/actionReducer.spec.ts @@ -1,9 +1,8 @@ import { actionReducer } from './actionReducer'; describe('actionReducer', () => { - it('spreads the init action onto state and starts count at 1', () => { + it('stores the init action type and starts count at 1', () => { const result = actionReducer(undefined, { type: '@@INIT' }); - // actionReducer always spreads the action, so type reflects the dispatched action expect(result.type).toBe('@@INIT'); expect(result.payload).toBeNull(); expect(result.meta).toBeNull(); @@ -11,7 +10,7 @@ describe('actionReducer', () => { expect(result.count).toBe(1); }); - it('spreads action onto state and increments count', () => { + it('stores action type and cloned payload', () => { const result = actionReducer(undefined, { type: 'MY_ACTION', payload: { id: 1 } }); expect(result.type).toBe('MY_ACTION'); expect(result.payload).toEqual({ id: 1 }); @@ -25,16 +24,33 @@ describe('actionReducer', () => { expect(state3.count).toBe(3); }); - it('preserves existing state fields not overridden by action', () => { + it('replaces type from previous action', () => { const initial = actionReducer(undefined, { type: 'FIRST', payload: 'original' }); const result = actionReducer(initial, { type: 'SECOND' }); expect(result.type).toBe('SECOND'); + expect(result.payload).toBeNull(); expect(result.count).toBe(2); }); - it('spreads action.meta and action.error from action onto state', () => { + it('stores meta and error from action', () => { const result = actionReducer(undefined, { type: 'ERR', meta: { source: 'api' }, error: true }); expect(result.meta).toEqual({ source: 'api' }); expect(result.error).toBe(true); }); + + it('deep-clones payload to prevent shared references', () => { + const nested = { data: { counterInfo: { id: 1, count: 20 } } }; + const result = actionReducer(undefined, { type: 'TEST', payload: nested }); + expect(result.payload).toEqual(nested); + expect(result.payload).not.toBe(nested); + expect((result.payload as any).data).not.toBe(nested.data); + expect((result.payload as any).data.counterInfo).not.toBe(nested.data.counterInfo); + }); + + it('deep-clones meta to prevent shared references', () => { + const meta = { source: { nested: true } }; + const result = actionReducer(undefined, { type: 'TEST', meta }); + expect(result.meta).toEqual(meta); + expect(result.meta).not.toBe(meta); + }); }); diff --git a/webclient/src/store/actions/actionReducer.ts b/webclient/src/store/actions/actionReducer.ts index b3e883ee1..03e32d1d8 100644 --- a/webclient/src/store/actions/actionReducer.ts +++ b/webclient/src/store/actions/actionReducer.ts @@ -25,19 +25,21 @@ const initialState: InitialState = { } /** - * Calculates the application state. + * Stores the most recent action so `useReduxEffect` can react to dispatches. * - * @param state - * @param action - * @return {*} + * Payloads are deep-cloned to prevent shared object references between this + * slice and the slice that owns the action. Without the clone, Immer mutations + * in the target slice are detected as mutations of the stale payload stored here. */ export const actionReducer = ( state = initialState, action: UnknownAction, ): InitialState => { return { - ...state, - ...action, + type: action.type ?? null, + payload: 'payload' in action ? structuredClone(action.payload) : null, + meta: 'meta' in action ? structuredClone(action.meta) : null, + error: !!action.error, count: state.count + 1, } } diff --git a/webclient/src/store/common/SortUtil.spec.ts b/webclient/src/store/common/SortUtil.spec.ts index 5a79b0e29..78f9c6aef 100644 --- a/webclient/src/store/common/SortUtil.spec.ts +++ b/webclient/src/store/common/SortUtil.spec.ts @@ -2,7 +2,6 @@ import { create } from '@bufbuild/protobuf'; import { App, Data } from '@app/types'; import SortUtil from './SortUtil'; -// ── sortByField ─────────────────────────────────────────────────────────────── describe('sortByField', () => { it('sorts string field ASC alphabetically', () => { @@ -55,7 +54,6 @@ describe('sortByField', () => { }); }); -// ── sortByFields ────────────────────────────────────────────────────────────── describe('sortByFields', () => { it('sorts by the first key when all items have distinct first-key values', () => { @@ -114,7 +112,6 @@ describe('sortByFields', () => { }); }); -// ── sortUsersByField ────────────────────────────────────────────────────────── describe('sortUsersByField', () => { it('sorts by userLevel DESC first, then name ASC', () => { @@ -147,7 +144,6 @@ describe('sortUsersByField', () => { }); }); -// ── toggleSortBy ────────────────────────────────────────────────────────────── describe('toggleSortBy', () => { it('same field + ASC → returns DESC', () => { @@ -166,7 +162,6 @@ describe('toggleSortBy', () => { }); }); -// ── resolveFieldChain with numeric index ───────────────────────────────────── describe('resolveFieldChain via sortByField (numeric index)', () => { it('resolves numeric index in dot-notation chain', () => { diff --git a/webclient/src/store/common/normalizers.spec.ts b/webclient/src/store/common/normalizers.spec.ts index a6bd6d757..a4195ed2b 100644 --- a/webclient/src/store/common/normalizers.spec.ts +++ b/webclient/src/store/common/normalizers.spec.ts @@ -77,11 +77,23 @@ describe('normalizeLogs', () => { const result = normalizeLogs(logs); expect(result.room).toHaveLength(2); expect(result.game).toHaveLength(1); - expect(result.chat).toBeUndefined(); + expect(result.chat).toEqual([]); }); - it('returns empty object for empty logs', () => { - expect(normalizeLogs([])).toEqual({}); + it('returns all three keys as empty arrays for empty logs', () => { + expect(normalizeLogs([])).toEqual({ room: [], game: [], chat: [] }); + }); + + it('skips logs whose targetType is not one of the known buckets', () => { + const logs = [ + create(Data.ServerInfo_ChatMessageSchema, { targetType: 'room' }), + create(Data.ServerInfo_ChatMessageSchema, { targetType: '' }), + create(Data.ServerInfo_ChatMessageSchema, { targetType: 'unknown' }), + ]; + const result = normalizeLogs(logs); + expect(result.room).toHaveLength(1); + expect(result.game).toEqual([]); + expect(result.chat).toEqual([]); }); }); diff --git a/webclient/src/store/common/normalizers.ts b/webclient/src/store/common/normalizers.ts index 545c3d8b9..3648cc8fb 100644 --- a/webclient/src/store/common/normalizers.ts +++ b/webclient/src/store/common/normalizers.ts @@ -1,6 +1,5 @@ import { Data, Enriched } from '@app/types'; -/** Flatten a gametype list into a lookup map of { gameTypeId → description }. */ export function normalizeGametypeMap(gametypeList: Data.ServerInfo_GameType[]): Enriched.GametypeMap { return gametypeList.reduce((map, type) => { map[type.gameTypeId] = type.description; @@ -8,13 +7,6 @@ export function normalizeGametypeMap(gametypeList: Data.ServerInfo_GameType[]): }, {}); } -/** - * Build an Enriched.Room (composition shape) from a raw proto. The proto is - * stored verbatim on `info` and the repeated collections are normalized into - * keyed maps alongside it. `info.gameList`, `info.userList`, `info.gametypeList` - * are left as the wire snapshot — callers should always read the normalized - * fields, never those. - */ export function normalizeRoomInfo(roomInfo: Data.ServerInfo_Room): Enriched.Room { const gametypeMap = normalizeGametypeMap(roomInfo.gametypeList); @@ -38,7 +30,6 @@ export function normalizeRoomInfo(roomInfo: Data.ServerInfo_Room): Enriched.Room }; } -/** Wrap a raw ServerInfo_Game in the composition shape with cached gameType. */ export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enriched.GametypeMap): Enriched.Game { const { gameTypes } = game; const hasType = gameTypes && gameTypes.length; @@ -48,22 +39,18 @@ export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enr }; } -/** Group a flat LogItem[] into { room, game, chat } buckets for the server store. */ export function normalizeLogs(logs: Data.ServerInfo_ChatMessage[]): Enriched.LogGroups { - return logs.reduce((obj, log) => { + return logs.reduce((obj, log) => { const type = log.targetType as keyof Enriched.LogGroups; - obj[type] = obj[type] || []; - obj[type]!.push(log); + if (obj[type]) { + obj[type].push(log); + } return obj; - }, {} as Enriched.LogGroups); + }, { room: [], game: [], chat: [] }); } -/** - * Prepend "name: " to the message text when a sender name is present. - * Messages from the current user are sent without a name by the server, - * so this is a no-op for those. - * Returns a new Message — does not mutate the original. - */ +// @critical Server omits `name` on messages from the current user; preserves that as a no-op. +// See .github/instructions/webclient.instructions.md#protocol-quirks. export function normalizeUserMessage(message: Enriched.Message): Enriched.Message { if (!message.name) { return message; @@ -71,12 +58,7 @@ export function normalizeUserMessage(message: Enriched.Message): Enriched.Messag return { ...message, message: `${message.name}: ${message.message}` }; } -/** - * Build the user-facing ban error string from raw server data. - * The server sends a reason string and an endTime epoch ms (0 = permanent). - * Messages from the current user do not carry the username — this quirk is - * handled at the dispatch layer so the redux store always stores a clean string. - */ +// endTime is epoch ms; 0 means permanent. export function normalizeBannedUserError(reason: string, endTime: number): string { let error: string; diff --git a/webclient/src/store/game/game.dispatch.spec.ts b/webclient/src/store/game/game.dispatch.spec.ts index d7cab22fb..bf6d91254 100644 --- a/webclient/src/store/game/game.dispatch.spec.ts +++ b/webclient/src/store/game/game.dispatch.spec.ts @@ -197,7 +197,7 @@ describe('Dispatch', () => { }); it('gameSay dispatches Actions.gameSay()', () => { - Dispatch.gameSay(1, 2, 'gg wp'); - expect(mockDispatch).toHaveBeenCalledWith(Actions.gameSay({ gameId: 1, playerId: 2, message: 'gg wp' })); + Dispatch.gameSay(1, 2, 'gg wp', 1700000000000); + expect(mockDispatch).toHaveBeenCalledWith(Actions.gameSay({ gameId: 1, playerId: 2, message: 'gg wp', timeReceived: 1700000000000 })); }); }); diff --git a/webclient/src/store/game/game.dispatch.ts b/webclient/src/store/game/game.dispatch.ts index c84a559e8..294f4831a 100644 --- a/webclient/src/store/game/game.dispatch.ts +++ b/webclient/src/store/game/game.dispatch.ts @@ -127,7 +127,7 @@ export const Dispatch = { store.dispatch(Actions.zonePropertiesChanged({ gameId, playerId, data })); }, - gameSay: (gameId: number, playerId: number, message: string) => { - store.dispatch(Actions.gameSay({ gameId, playerId, message })); + gameSay: (gameId: number, playerId: number, message: string, timeReceived: number) => { + store.dispatch(Actions.gameSay({ gameId, playerId, message, timeReceived })); }, }; diff --git a/webclient/src/store/game/game.reducer.spec.ts b/webclient/src/store/game/game.reducer.spec.ts index 3d4b8abb8..6a467f145 100644 --- a/webclient/src/store/game/game.reducer.spec.ts +++ b/webclient/src/store/game/game.reducer.spec.ts @@ -20,7 +20,6 @@ function cardsIn(state: GamesState, gameId: number, playerId: number, zoneName: return zone ? zone.order.map(id => zone.byId[id]) : []; } -// ── 2A: Initialisation & lifecycle ─────────────────────────────────────────── describe('2A: Initialisation & lifecycle', () => { it('returns initialState ({ games: {} }) when called with undefined state', () => { @@ -77,7 +76,6 @@ describe('2A: Initialisation & lifecycle', () => { }); }); -// ── 2B: Game state & player management ─────────────────────────────────────── describe('2B: Game state & player management', () => { it('GAME_STATE_CHANGED with playerList → replaces players via normalizePlayers', () => { @@ -165,7 +163,6 @@ describe('2B: Game state & player management', () => { }); }); -// ── 2C: CARD_MOVED ──────────────────────────────────────────────────────────── describe('2C: CARD_MOVED', () => { function stateWithCard(cardOverrides: Parameters[0] = {}) { @@ -417,9 +414,71 @@ describe('2C: CARD_MOVED', () => { expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(1); expect(result.games[1].players[1].zones['nonexistent']).toBeUndefined(); }); + + it('CARD_MOVED → no-ops when neither cardId nor position resolve and newCardId < 0', () => { + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: 'deck', cards: [], cardCount: 5 }), + hand: makeZoneEntry({ name: 'hand', cards: [], cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, Actions.cardMoved({ + gameId: 1, + playerId: 1, + data: { + cardId: -1, cardName: '', startPlayerId: 1, startZone: 'deck', + position: -1, targetPlayerId: 1, targetZone: 'hand', + x: 0, y: 0, newCardId: -1, faceDown: false, newCardProviderId: '', + }, + })); + + expect(result.games[1].players[1].zones['deck'].cardCount).toBe(5); + expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(0); + }); + + it('CARD_MOVED → deep-clones counterList so moved card is independent', () => { + const cardCounter = create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 3 }); + const card = makeCard({ id: 10, counterList: [cardCounter] }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + hand: makeZoneEntry({ name: 'hand', cards: [card], cardCount: 1 }), + table: makeZoneEntry({ name: 'table', cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, Actions.cardMoved({ + gameId: 1, + playerId: 1, + data: { + cardId: 10, cardName: '', startPlayerId: 1, startZone: 'hand', + position: -1, targetPlayerId: 1, targetZone: 'table', + x: 0, y: 0, newCardId: -1, faceDown: false, newCardProviderId: '', + }, + })); + + const movedCard = cardsIn(result, 1, 1, 'table')[0]; + expect(movedCard.counterList).toHaveLength(1); + expect(movedCard.counterList).not.toBe(card.counterList); + }); }); -// ── 2D: Card mutations ──────────────────────────────────────────────────────── describe('2D: Card mutations', () => { function stateWithCardInZone(zoneName: string) { @@ -524,7 +583,6 @@ describe('2D: Card mutations', () => { }); }); -// ── 2E: CARD_ATTR_CHANGED ───────────────────────────────────────────────────── describe('2E: CARD_ATTR_CHANGED', () => { function stateWithCard() { @@ -597,7 +655,6 @@ describe('2E: CARD_ATTR_CHANGED', () => { }); }); -// ── 2F: CARD_COUNTER_CHANGED ───────────────────────────────────────────────── describe('2F: CARD_COUNTER_CHANGED', () => { function stateWithCard(existingCounters: any[] = []) { @@ -648,7 +705,6 @@ describe('2F: CARD_COUNTER_CHANGED', () => { }); }); -// ── 2G: Arrows ──────────────────────────────────────────────────────────────── describe('2G: Arrows', () => { it('ARROW_CREATED → inserts arrowInfo into player.arrows keyed by id', () => { @@ -682,7 +738,6 @@ describe('2G: Arrows', () => { }); }); -// ── 2H: Player counters ─────────────────────────────────────────────────────── describe('2H: Player counters', () => { it('COUNTER_CREATED → inserts counterInfo into player.counters keyed by id', () => { @@ -696,6 +751,17 @@ describe('2H: Player counters', () => { expect(result.games[1].players[1].counters[5]).toEqual(counter); }); + it('COUNTER_CREATED → clones counterInfo to prevent shared references', () => { + const state = makeState(); + const counter = makeCounter({ id: 5, name: 'Life', count: 20 }); + const result = gamesReducer(state, Actions.counterCreated({ + gameId: 1, + playerId: 1, + data: { counterInfo: counter }, + })); + expect(result.games[1].players[1].counters[5]).not.toBe(counter); + }); + it('COUNTER_SET → updates counter.count to new value', () => { const counter = makeCounter({ id: 5, count: 20 }); const state = makeState({ @@ -735,7 +801,6 @@ describe('2H: Player counters', () => { }); }); -// ── 2I: Zone operations ─────────────────────────────────────────────────────── describe('2I: Zone operations', () => { it('CARDS_DRAWN → decrements deck.cardCount, appends cards to hand, increments hand.cardCount', () => { @@ -845,6 +910,34 @@ describe('2I: Zone operations', () => { expect(cardsIn(result, 1, 1, 'deck')[1]).toEqual(newCard); }); + it('CARDS_REVEALED → clones counterList to prevent shared references', () => { + const cardCounter = create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 5 }); + const revealedCard = makeCard({ id: 3, counterList: [cardCounter] }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: 'deck', cards: [], cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, Actions.cardsRevealed({ + gameId: 1, + playerId: 1, + data: { zoneName: 'deck', cards: [revealedCard] }, + })); + + const stored = cardsIn(result, 1, 1, 'deck')[0]; + expect(stored.counterList).toEqual(revealedCard.counterList); + expect(stored.counterList).not.toBe(revealedCard.counterList); + }); + it('ZONE_PROPERTIES_CHANGED → sets alwaysRevealTopCard and alwaysLookAtTopCard', () => { const state = makeState(); const result = gamesReducer(state, Actions.zonePropertiesChanged({ @@ -861,7 +954,6 @@ describe('2I: Zone operations', () => { }); }); -// ── 2J: Turn / phase / chat ─────────────────────────────────────────────────── describe('2J: Turn, phase, and chat', () => { it('ACTIVE_PLAYER_SET → sets game.activePlayerId', () => { @@ -882,25 +974,20 @@ describe('2J: Turn, phase, and chat', () => { expect(result.games[1].reversed).toBe(true); }); - it('GAME_SAY → appends message with mocked Date.now() as timeReceived', () => { + it('GAME_SAY → appends message with timeReceived from payload', () => { const state = makeState(); - const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456789); - try { - const result = gamesReducer(state, Actions.gameSay({ - gameId: 1, - playerId: 2, - message: 'gg', - })); + const result = gamesReducer(state, Actions.gameSay({ + gameId: 1, + playerId: 2, + message: 'gg', + timeReceived: 123456789, + })); - expect(result.games[1].messages).toHaveLength(1); - expect(result.games[1].messages[0]).toEqual({ playerId: 2, message: 'gg', timeReceived: 123456789 }); - } finally { - dateNowSpy.mockRestore(); - } + expect(result.games[1].messages).toHaveLength(1); + expect(result.games[1].messages[0]).toEqual({ playerId: 2, message: 'gg', timeReceived: 123456789 }); }); }); -// ── 2K: No-op / passthrough actions ────────────────────────────────────────── describe('2K: No-op / passthrough actions', () => { it('ZONE_SHUFFLED → returns state unchanged (identity)', () => { @@ -928,7 +1015,6 @@ describe('2K: No-op / passthrough actions', () => { }); }); -// ── 2L: Null-guard / missing entity early-returns ───────────────────────────── // Each test dispatches an action with a non-existent gameId (999) or playerId/zone // to exercise the `if (!game) return state` / `if (!player) return state` guards. @@ -1332,6 +1418,6 @@ describe('2L: Null-guard / missing entity early-returns', () => { it('GAME_SAY with unknown gameId → state unchanged', () => { const state = makeState(); - expect(gamesReducer(state, Actions.gameSay({ gameId: UNKNOWN_GAME, playerId: 1, message: 'hi' }))).toBe(state); + expect(gamesReducer(state, Actions.gameSay({ gameId: UNKNOWN_GAME, playerId: 1, message: 'hi', timeReceived: 0 }))).toBe(state); }); }); diff --git a/webclient/src/store/game/game.reducer.ts b/webclient/src/store/game/game.reducer.ts index eff362c32..62e06da21 100644 --- a/webclient/src/store/game/game.reducer.ts +++ b/webclient/src/store/game/game.reducer.ts @@ -177,13 +177,12 @@ export const gamesSlice = createSlice({ } }, - // ── Card manipulation ──────────────────────────────────────────────────── cardMoved: ( state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_MoveCard }>, ) => { - const { gameId, playerId, data } = action.payload; + const { gameId, data } = action.payload; const { cardId, cardName, startPlayerId, startZone, position, targetPlayerId, targetZone, x, y, newCardId, faceDown, newCardProviderId, @@ -194,8 +193,7 @@ export const gamesSlice = createSlice({ return; } - const effectiveStartPlayerId = startPlayerId >= 0 ? startPlayerId : playerId; - const sourcePlayer = game.players[effectiveStartPlayerId]; + const sourcePlayer = game.players[startPlayerId]; const sourceZone = sourcePlayer?.zones[startZone]; if (!sourcePlayer || !sourceZone) { return; @@ -214,10 +212,16 @@ export const gamesSlice = createSlice({ resolvedCardId = sourceZone.order[position]; } - const removedCard: Data.ServerInfo_Card | undefined = - resolvedCardId >= 0 ? sourceZone.byId[resolvedCardId] : undefined; + // If the card can't be resolved and no newCardId is provided, the event + // is malformed — bail out to avoid creating phantom cards with id -1. + if (resolvedCardId < 0 && newCardId < 0) { + return; + } + // Remove from source zone if the card was resolved to a known entry + let removedCard: Data.ServerInfo_Card | undefined; if (resolvedCardId >= 0) { + removedCard = sourceZone.byId[resolvedCardId]; const idx = sourceZone.order.indexOf(resolvedCardId); if (idx >= 0) { sourceZone.order.splice(idx, 1); @@ -226,11 +230,12 @@ export const gamesSlice = createSlice({ } sourceZone.cardCount = Math.max(0, sourceZone.cardCount - 1); - const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? -1); + const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? resolvedCardId); const movedCard: Data.ServerInfo_Card = removedCard ? { ...removedCard, id: effectiveNewId, name: cardName || removedCard.name, x, y, faceDown, providerId: newCardProviderId || removedCard.providerId, + counterList: [...removedCard.counterList], } : buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? ''); @@ -337,13 +342,12 @@ export const gamesSlice = createSlice({ } }, - // ── Arrows ─────────────────────────────────────────────────────────────── arrowCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateArrow }>) => { const { gameId, playerId, data } = action.payload; const player = state.games[gameId]?.players[playerId]; - if (player) { - player.arrows[data.arrowInfo.id] = data.arrowInfo; + if (player && data.arrowInfo) { + player.arrows[data.arrowInfo.id] = { ...data.arrowInfo }; } }, @@ -355,13 +359,12 @@ export const gamesSlice = createSlice({ } }, - // ── Player counters ─────────────────────────────────────────────────────── counterCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateCounter }>) => { const { gameId, playerId, data } = action.payload; const player = state.games[gameId]?.players[playerId]; - if (player) { - player.counters[data.counterInfo.id] = data.counterInfo; + if (player && data.counterInfo) { + player.counters[data.counterInfo.id] = { ...data.counterInfo }; } }, @@ -381,7 +384,6 @@ export const gamesSlice = createSlice({ } }, - // ── Zone operations ─────────────────────────────────────────────────────── cardsDrawn: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DrawCards }>) => { const { gameId, playerId, data } = action.payload; @@ -417,12 +419,10 @@ export const gamesSlice = createSlice({ } for (const revealedCard of cards) { - if (zone.byId[revealedCard.id]) { - Object.assign(zone.byId[revealedCard.id], revealedCard); - } else { + if (!zone.byId[revealedCard.id]) { zone.order.push(revealedCard.id); - zone.byId[revealedCard.id] = revealedCard; } + zone.byId[revealedCard.id] = { ...revealedCard, counterList: [...revealedCard.counterList] }; } }, @@ -440,7 +440,6 @@ export const gamesSlice = createSlice({ } }, - // ── Turn / phase ────────────────────────────────────────────────────────── activePlayerSet: (state, action: PayloadAction<{ gameId: number; activePlayerId: number }>) => { const game = state.games[action.payload.gameId]; @@ -463,10 +462,9 @@ export const gamesSlice = createSlice({ } }, - // ── Chat ────────────────────────────────────────────────────────────────── - gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string }>) => { - const { gameId, playerId, message } = action.payload; + gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string; timeReceived: number }>) => { + const { gameId, playerId, message, timeReceived } = action.payload; const game = state.games[gameId]; if (!game) { return; @@ -474,10 +472,9 @@ export const gamesSlice = createSlice({ if (game.messages.length >= MAX_GAME_MESSAGES) { game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1); } - game.messages.push({ playerId, message, timeReceived: Date.now() }); + game.messages.push({ playerId, message, timeReceived }); }, - // ── Log-only events ───────────────────────────────────────────────────── zoneShuffled: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_Shuffle }>) => {}, zoneDumped: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DumpZone }>) => {}, dieRolled: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_RollDie }>) => {}, diff --git a/webclient/src/store/rooms/rooms.actions.spec.ts b/webclient/src/store/rooms/rooms.actions.spec.ts index ba75c5f2a..59a806160 100644 --- a/webclient/src/store/rooms/rooms.actions.spec.ts +++ b/webclient/src/store/rooms/rooms.actions.spec.ts @@ -42,9 +42,10 @@ describe('Actions', () => { }); it('sortGames', () => { - expect(Actions.sortGames({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })).toEqual({ + expect(Actions.sortGames({ roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })).toEqual({ type: Types.SORT_GAMES, payload: { + roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC, }, diff --git a/webclient/src/store/rooms/rooms.dispatch.spec.ts b/webclient/src/store/rooms/rooms.dispatch.spec.ts index 0cec338f3..83affda0a 100644 --- a/webclient/src/store/rooms/rooms.dispatch.spec.ts +++ b/webclient/src/store/rooms/rooms.dispatch.spec.ts @@ -1,10 +1,5 @@ -// Use `vi.hoisted` so the mocked `store.dispatch` reference stays stable across -// re-runs of the factory under `isolate: false`. Other dispatch specs mock the -// same `..` path with their own factories; under the shared module graph, the -// cache entry for `..` can flip between competing `vi.fn()` instances. Asserting -// against the hoisted `mockDispatch` directly (rather than reaching through -// `store.dispatch`) decouples the assertions from whatever the module cache -// currently resolves `store` to. +// Hoisted so the mockDispatch reference is available inside the vi.mock factory +// below and can be asserted against directly from each test. const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); vi.mock('..', () => ({ store: { dispatch: mockDispatch } })); @@ -74,7 +69,7 @@ describe('Dispatch', () => { it('sortGames dispatches Actions.sortGames()', () => { Dispatch.sortGames(1, App.GameSortField.START_TIME, App.SortDirection.ASC); expect(mockDispatch).toHaveBeenCalledWith( - Actions.sortGames({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC }) + Actions.sortGames({ roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC }) ); }); diff --git a/webclient/src/store/rooms/rooms.dispatch.tsx b/webclient/src/store/rooms/rooms.dispatch.tsx index a6eb8dd03..224eff0d9 100644 --- a/webclient/src/store/rooms/rooms.dispatch.tsx +++ b/webclient/src/store/rooms/rooms.dispatch.tsx @@ -37,7 +37,7 @@ export const Dispatch = { }, sortGames: (roomId: number, field: App.GameSortField, order: App.SortDirection) => { - store.dispatch(Actions.sortGames({ field, order })); + store.dispatch(Actions.sortGames({ roomId, field, order })); }, removeMessages: (roomId: number, name: string, amount: number) => { diff --git a/webclient/src/store/rooms/rooms.reducer.spec.ts b/webclient/src/store/rooms/rooms.reducer.spec.ts index ea09fefc7..6e7f5fbfc 100644 --- a/webclient/src/store/rooms/rooms.reducer.spec.ts +++ b/webclient/src/store/rooms/rooms.reducer.spec.ts @@ -4,7 +4,6 @@ import { Actions } from './rooms.actions'; import { MAX_ROOM_MESSAGES } from './rooms.types'; import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures'; -// ── Initialisation ─────────────────────────────────────────────────────────── describe('Initialisation', () => { it('returns initialState when called with undefined state', () => { @@ -27,7 +26,6 @@ describe('Initialisation', () => { }); }); -// ── UPDATE_ROOMS ────────────────────────────────────────────────────────────── describe('UPDATE_ROOMS', () => { it('creates RoomEntry with empty normalized games/users for new room', () => { @@ -77,7 +75,6 @@ describe('UPDATE_ROOMS', () => { }); }); -// ── JOIN_ROOM ────────────────────────────────────────────────────────────────── describe('JOIN_ROOM', () => { it('normalizes raw room into keyed games/users maps and marks joined', () => { @@ -97,7 +94,6 @@ describe('JOIN_ROOM', () => { }); }); -// ── LEAVE_ROOM ──────────────────────────────────────────────────────────────── describe('LEAVE_ROOM', () => { it('removes joinedRoomIds entry and messages for roomId', () => { @@ -111,7 +107,6 @@ describe('LEAVE_ROOM', () => { }); }); -// ── ADD_MESSAGE ─────────────────────────────────────────────────────────────── describe('ADD_MESSAGE', () => { it('appends message preserving the timeReceived from the event handler', () => { @@ -157,7 +152,6 @@ describe('ADD_MESSAGE', () => { }); }); -// ── UPDATE_GAMES ────────────────────────────────────────────────────────────── describe('UPDATE_GAMES', () => { it('removes closed games from the keyed games map', () => { @@ -211,7 +205,6 @@ describe('UPDATE_GAMES', () => { }); }); -// ── USER_JOINED / USER_LEFT ─────────────────────────────────────────────────── describe('USER_JOINED', () => { it('inserts user into the keyed users map', () => { @@ -237,12 +230,12 @@ describe('USER_LEFT', () => { }); }); -// ── SORT_GAMES ──────────────────────────────────────────────────────────────── describe('SORT_GAMES', () => { it('updates sortGamesBy on state (sorting itself is now derived in selectors)', () => { const state = makeRoomsState({ rooms: {} }); const result = roomsReducer(state, Actions.sortGames({ + roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC, })); @@ -250,7 +243,6 @@ describe('SORT_GAMES', () => { }); }); -// ── REMOVE_MESSAGES ─────────────────────────────────────────────────────────── describe('REMOVE_MESSAGES', () => { it('removes messages starting with "name:" up to amount, in reverse scan order', () => { @@ -293,7 +285,6 @@ describe('REMOVE_MESSAGES', () => { }); }); -// ── GAME_CREATED ────────────────────────────────────────────────────────────── describe('GAME_CREATED', () => { it('returns state unchanged', () => { @@ -303,7 +294,6 @@ describe('GAME_CREATED', () => { }); }); -// ── JOINED_GAME ─────────────────────────────────────────────────────────────── describe('JOINED_GAME', () => { it('sets joinedGameIds[roomId][gameId] = true', () => { diff --git a/webclient/src/store/rooms/rooms.reducer.tsx b/webclient/src/store/rooms/rooms.reducer.tsx index efd2c916d..0482b4e3a 100644 --- a/webclient/src/store/rooms/rooms.reducer.tsx +++ b/webclient/src/store/rooms/rooms.reducer.tsx @@ -31,9 +31,8 @@ export const roomsSlice = createSlice({ updateRooms: (state, action: PayloadAction<{ rooms: Data.ServerInfo_Room[] }>) => { const { rooms } = action.payload; - // UPDATE_ROOMS carries metadata only. For existing rooms, replace - // `info`, `gametypeMap` and `order`; preserve the normalized `games` - // and `users` maps (those are maintained by their own events). + // @critical Partial merge — preserve normalized games/users maps. + // See .github/instructions/webclient.instructions.md#reducer-merge-rules. rooms.forEach((rawRoom, order) => { const { roomId } = rawRoom; const existing = state.rooms[roomId]; @@ -82,22 +81,20 @@ export const roomsSlice = createSlice({ addMessage: (state, action: PayloadAction<{ roomId: number; message: Enriched.Message }>) => { const { roomId, message } = action.payload; - const existing = state.messages[roomId] ?? []; - const normalized = normalizeUserMessage(message); - const next = - existing.length >= MAX_ROOM_MESSAGES - ? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized] - : [...existing, normalized]; - - state.messages[roomId] = next; + if (!state.messages[roomId]) { + state.messages[roomId] = []; + } + const msgs = state.messages[roomId]; + if (msgs.length >= MAX_ROOM_MESSAGES) { + state.messages[roomId] = msgs.slice(msgs.length - MAX_ROOM_MESSAGES + 1); + } + state.messages[roomId].push(normalizeUserMessage(message)); }, updateGames: (state, action: PayloadAction<{ roomId: number; games: Data.ServerInfo_Game[] }>) => { const { roomId, games } = action.payload; const room = state.rooms[roomId]; - // An empty games array means no game updates — skip to avoid - // accidentally wiping the existing normalized games map. if (!room || !games?.length) { return; } @@ -111,7 +108,6 @@ export const roomsSlice = createSlice({ } const existing = room.games[rawGame.gameId]; if (existing) { - // Merge the incoming proto into the existing snapshot. const merged: Data.ServerInfo_Game = { ...existing.info, ...rawGame }; room.games[rawGame.gameId] = { info: merged, @@ -145,8 +141,7 @@ export const roomsSlice = createSlice({ delete room.users[name]; }, - sortGames: (state, action: PayloadAction<{ field: App.GameSortField; order: App.SortDirection }>) => { - // Sort is now derived in selectors; the reducer only stores the sort config. + sortGames: (state, action: PayloadAction<{ roomId: number; field: App.GameSortField; order: App.SortDirection }>) => { const { field, order } = action.payload; state.sortGamesBy = { field, order }; }, @@ -159,8 +154,6 @@ export const roomsSlice = createSlice({ return; } - // Drop the `amount` most-recent messages whose text starts with `${name}:`. - // Walk newest → oldest so we remove the N latest matches. const prefix = `${name}:`; const keep = new Array(roomMessages.length).fill(true); let remaining = amount; @@ -183,7 +176,7 @@ export const roomsSlice = createSlice({ state.joinedGameIds[roomId][gameId] = true; }, - // Signal-only — no state mutation needed; explicit for discriminated-union exhaustiveness + // Signal-only; kept for discriminated-union exhaustiveness. gameCreated: (_state, _action: PayloadAction<{ roomId: number }>) => {}, }, }); diff --git a/webclient/src/store/rooms/rooms.selectors.spec.ts b/webclient/src/store/rooms/rooms.selectors.spec.ts index adbbcfe04..4308aba86 100644 --- a/webclient/src/store/rooms/rooms.selectors.spec.ts +++ b/webclient/src/store/rooms/rooms.selectors.spec.ts @@ -146,7 +146,6 @@ describe('Selectors', () => { expect(Selectors.getSortedRoomUsers(rootState(state), 999)).toHaveLength(0); }); - // ── createSelector reference stability ────────────────────────────── it('getSortedRoomGames → returns same array reference for identical state', () => { const game = makeGame({ gameId: 1 }); diff --git a/webclient/src/store/server/__mocks__/server-fixtures.ts b/webclient/src/store/server/__mocks__/server-fixtures.ts index be47e93e2..9bb7c8cf4 100644 --- a/webclient/src/store/server/__mocks__/server-fixtures.ts +++ b/webclient/src/store/server/__mocks__/server-fixtures.ts @@ -1,4 +1,5 @@ import { App, Data, Enriched } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import type { MessageInitShape } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf'; @@ -117,8 +118,8 @@ export function makeGame(overrides: MakeGameOverrides = {}): Enriched.Game { } export function makeLoginSuccessContext( - overrides: Partial = {} -): Enriched.LoginSuccessContext { + overrides: Partial = {} +): WebsocketTypes.LoginSuccessContext { return { hashedPassword: 'hash', ...overrides, @@ -126,8 +127,8 @@ export function makeLoginSuccessContext( } export function makePendingActivationContext( - overrides: Partial = {} -): Enriched.PendingActivationContext { + overrides: Partial = {} +): WebsocketTypes.PendingActivationContext { return { host: 'localhost', port: '4747', @@ -143,7 +144,7 @@ export function makeServerState(overrides: Partial = {}): ServerSta ignoreList: {}, status: { connectionAttemptMade: false, - state: App.StatusEnum.DISCONNECTED, + state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null, }, info: { diff --git a/webclient/src/store/server/server.actions.spec.ts b/webclient/src/store/server/server.actions.spec.ts index 3638201b7..f741086c5 100644 --- a/webclient/src/store/server/server.actions.spec.ts +++ b/webclient/src/store/server/server.actions.spec.ts @@ -1,5 +1,6 @@ import { Actions } from './server.actions'; -import { App, Data } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { Types } from './server.types'; import { create } from '@bufbuild/protobuf'; import { @@ -88,7 +89,7 @@ describe('Actions', () => { }); it('updateStatus', () => { - const status = { state: App.StatusEnum.CONNECTED, description: 'connected' }; + const status = { state: WebsocketTypes.StatusEnum.CONNECTED, description: 'connected' }; expect(Actions.updateStatus({ status })).toEqual({ type: Types.UPDATE_STATUS, payload: { status } }); }); diff --git a/webclient/src/store/server/server.dispatch.spec.ts b/webclient/src/store/server/server.dispatch.spec.ts index 3d89c6330..bab47faa7 100644 --- a/webclient/src/store/server/server.dispatch.spec.ts +++ b/webclient/src/store/server/server.dispatch.spec.ts @@ -1,12 +1,11 @@ -// Use `vi.hoisted` so the mocked `store.dispatch` reference stays stable across -// re-runs of the factory under `isolate: false`. See rooms.dispatch.spec.ts for -// the same pattern and rationale. +// @critical See rooms.dispatch.spec.ts — same hoisted-mockDispatch pattern. const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); vi.mock('..', () => ({ store: { dispatch: mockDispatch } })); import { Actions } from './server.actions'; import { Dispatch } from './server.dispatch'; -import { App, Data } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { create } from '@bufbuild/protobuf'; import { makeBanHistoryItem, @@ -106,8 +105,10 @@ describe('Dispatch', () => { }); it('updateStatus dispatches Actions.updateStatus({ status: { state, description } })', () => { - Dispatch.updateStatus(App.StatusEnum.CONNECTED, 'ok'); - expect(mockDispatch).toHaveBeenCalledWith(Actions.updateStatus({ status: { state: App.StatusEnum.CONNECTED, description: 'ok' } })); + Dispatch.updateStatus(WebsocketTypes.StatusEnum.CONNECTED, 'ok'); + expect(mockDispatch).toHaveBeenCalledWith( + Actions.updateStatus({ status: { state: WebsocketTypes.StatusEnum.CONNECTED, description: 'ok' } }) + ); }); it('updateUser dispatches Actions.updateUser()', () => { diff --git a/webclient/src/store/server/server.dispatch.ts b/webclient/src/store/server/server.dispatch.ts index 77a2845df..823085e0b 100644 --- a/webclient/src/store/server/server.dispatch.ts +++ b/webclient/src/store/server/server.dispatch.ts @@ -1,6 +1,7 @@ import { Actions } from './server.actions'; import { store } from '..'; -import { App, Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; export const Dispatch = { initialized: () => { @@ -12,7 +13,7 @@ export const Dispatch = { connectionAttempted: () => { store.dispatch(Actions.connectionAttempted()); }, - loginSuccessful: (options: Enriched.LoginSuccessContext) => { + loginSuccessful: (options: WebsocketTypes.LoginSuccessContext) => { store.dispatch(Actions.loginSuccessful({ options })); }, loginFailed: () => { @@ -48,7 +49,7 @@ export const Dispatch = { updateInfo: (name: string, version: string) => { store.dispatch(Actions.updateInfo({ info: { name, version } })); }, - updateStatus: (state: App.StatusEnum, description: string) => { + updateStatus: (state: WebsocketTypes.StatusEnum, description: string) => { store.dispatch(Actions.updateStatus({ status: { state, description } })); }, updateUser: (user: Data.ServerInfo_User) => { @@ -93,7 +94,7 @@ export const Dispatch = { registrationUserNameError: (error: string) => { store.dispatch(Actions.registrationUserNameError({ error })); }, - accountAwaitingActivation: (options: Enriched.PendingActivationContext) => { + accountAwaitingActivation: (options: WebsocketTypes.PendingActivationContext) => { store.dispatch(Actions.accountAwaitingActivation({ options })); }, accountActivationSuccess: () => { diff --git a/webclient/src/store/server/server.interfaces.ts b/webclient/src/store/server/server.interfaces.ts index 2a4e1ca5a..ea7f61d15 100644 --- a/webclient/src/store/server/server.interfaces.ts +++ b/webclient/src/store/server/server.interfaces.ts @@ -1,4 +1,5 @@ import { App, Data, Enriched } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; export interface ServerState { initialized: boolean; @@ -43,7 +44,7 @@ export interface ServerState { export interface ServerStateStatus { connectionAttemptMade: boolean; description: string | null; - state: App.StatusEnum; + state: WebsocketTypes.StatusEnum; } export interface ServerStateInfo { diff --git a/webclient/src/store/server/server.reducer.spec.ts b/webclient/src/store/server/server.reducer.spec.ts index 99ac79e97..328e10aa5 100644 --- a/webclient/src/store/server/server.reducer.spec.ts +++ b/webclient/src/store/server/server.reducer.spec.ts @@ -1,6 +1,7 @@ -import { App, Data } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { create } from '@bufbuild/protobuf'; -import { serverReducer } from './server.reducer'; +import { serverReducer, MAX_USER_MESSAGES } from './server.reducer'; import { Actions } from './server.actions'; import { makeBanHistoryItem, @@ -18,14 +19,13 @@ import { const UserLevelFlag = Data.ServerInfo_User_UserLevelFlag; -// ── Initialisation ─────────────────────────────────────────────────────────── describe('Initialisation', () => { it('returns initialState when called with undefined state', () => { const result = serverReducer(undefined, { type: '@@INIT' }); expect(result.initialized).toBe(false); expect(result.buddyList).toEqual({}); - expect(result.status.state).toBe(App.StatusEnum.DISCONNECTED); + expect(result.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('INITIALIZED → resets to initialState with initialized: true', () => { @@ -37,7 +37,7 @@ describe('Initialisation', () => { }); it('CLEAR_STORE → resets to initialState but preserves status', () => { - const status = { state: App.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true }; + const status = { state: WebsocketTypes.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true }; const state = makeServerState({ status, banUser: 'someone' }); const result = serverReducer(state, Actions.clearStore()); expect(result.banUser).toBe(''); @@ -52,11 +52,12 @@ describe('Initialisation', () => { }); }); -// ── Account & Connection ───────────────────────────────────────────────────── describe('Account & Connection', () => { it('CONNECTION_ATTEMPTED → sets connectionAttemptMade to true', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, + }); const result = serverReducer(state, Actions.connectionAttempted()); expect(result.status.connectionAttemptMade).toBe(true); }); @@ -81,7 +82,6 @@ describe('Account & Connection', () => { }); }); -// ── Registration ────────────────────────────────────────────────────────────── describe('Registration', () => { it('REGISTRATION_FAILED → stores normalized error (plain reason)', () => { @@ -110,7 +110,6 @@ describe('Registration', () => { }); }); -// ── Server Info & Status ────────────────────────────────────────────────────── describe('Server Info & Status', () => { it('SERVER_MESSAGE → merges message into state.info', () => { @@ -131,15 +130,14 @@ describe('Server Info & Status', () => { it('UPDATE_STATUS → merges state and description into status', () => { const state = makeServerState(); - const update = { state: App.StatusEnum.LOGGED_IN, description: 'ok' }; + const update = { state: WebsocketTypes.StatusEnum.LOGGED_IN, description: 'ok' }; const result = serverReducer(state, Actions.updateStatus({ status: update })); - expect(result.status.state).toBe(App.StatusEnum.LOGGED_IN); + expect(result.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); expect(result.status.description).toBe('ok'); expect(result.status.connectionAttemptMade).toBe(false); }); }); -// ── User ────────────────────────────────────────────────────────────────────── describe('User', () => { it('UPDATE_USER → merges action.payload.user into state.user', () => { @@ -163,7 +161,6 @@ describe('User', () => { }); }); -// ── Users List ──────────────────────────────────────────────────────────────── describe('Users List', () => { it('UPDATE_USERS → replaces users map keyed by name', () => { @@ -192,7 +189,6 @@ describe('Users List', () => { }); }); -// ── Buddy & Ignore Lists ────────────────────────────────────────────────────── describe('Buddy List', () => { it('UPDATE_BUDDY_LIST → replaces map keyed by name', () => { @@ -246,7 +242,6 @@ describe('Ignore List', () => { }); }); -// ── Logs ───────────────────────────────────────────────────────────────────── describe('Logs', () => { it('VIEW_LOGS → groups LogItem[] into room/game/chat buckets', () => { @@ -256,6 +251,14 @@ describe('Logs', () => { expect(result.logs.room).toEqual([log]); }); + it('VIEW_LOGS with empty array → produces all three keys as empty arrays', () => { + const state = makeServerState(); + const result = serverReducer(state, Actions.viewLogs({ logs: [] })); + expect(result.logs.room).toEqual([]); + expect(result.logs.game).toEqual([]); + expect(result.logs.chat).toEqual([]); + }); + it('CLEAR_LOGS → resets logs to empty arrays', () => { const state = makeServerState({ logs: { room: [makeLogItem()], game: [], chat: [] } }); const result = serverReducer(state, Actions.clearLogs()); @@ -265,7 +268,6 @@ describe('Logs', () => { }); }); -// ── Messaging ───────────────────────────────────────────────────────────────── describe('Messaging', () => { it('USER_MESSAGE → uses receiverName as key when current user is sender', () => { @@ -284,6 +286,13 @@ describe('Messaging', () => { expect(result.messages['Alice'][0]).toEqual(messageData); }); + it('USER_MESSAGE → no-ops when user is null (not yet logged in)', () => { + const state = makeServerState({ user: null, messages: {} }); + const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hi' } as Data.Event_UserMessage; + const result = serverReducer(state, Actions.userMessage({ messageData })); + expect(result.messages).toEqual({}); + }); + it('USER_MESSAGE → appends to existing messages for that user', () => { const existingMsg = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'first' }); const state = makeServerState({ @@ -294,9 +303,23 @@ describe('Messaging', () => { const result = serverReducer(state, Actions.userMessage({ messageData: newMsg })); expect(result.messages['Alice']).toHaveLength(2); }); + + it(`USER_MESSAGE → caps messages at MAX_USER_MESSAGES (${MAX_USER_MESSAGES})`, () => { + const messages = Array.from({ length: MAX_USER_MESSAGES }, (_, i) => + create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: `msg-${i}` }) + ); + const state = makeServerState({ + user: makeUser({ name: 'Bob' }), + messages: { Alice: messages }, + }); + const newMsg = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'overflow' }); + const result = serverReducer(state, Actions.userMessage({ messageData: newMsg })); + expect(result.messages['Alice']).toHaveLength(MAX_USER_MESSAGES); + expect(result.messages['Alice'][MAX_USER_MESSAGES - 1]).toEqual(newMsg); + expect(result.messages['Alice'][0].message).not.toBe('msg-0'); + }); }); -// ── User Info & Notifications ───────────────────────────────────────────────── describe('User Info & Notifications', () => { it('GET_USER_INFO → adds userInfo keyed by name', () => { @@ -322,7 +345,6 @@ describe('User Info & Notifications', () => { }); }); -// ── Moderation ──────────────────────────────────────────────────────────────── describe('Moderation', () => { it('BAN_FROM_SERVER → sets banUser', () => { @@ -371,7 +393,6 @@ describe('Moderation', () => { }); }); -// ── ADJUST_MOD ──────────────────────────────────────────────────────────────── describe('ADJUST_MOD', () => { const baseUserLevel = UserLevelFlag.IsUser | UserLevelFlag.IsRegistered | UserLevelFlag.IsModerator | UserLevelFlag.IsJudge; @@ -429,7 +450,6 @@ describe('ADJUST_MOD', () => { }); }); -// ── Replays ─────────────────────────────────────────────────────────────────── describe('Replays', () => { it('REPLAY_LIST → replaces replays map keyed by gameId', () => { @@ -482,7 +502,6 @@ describe('Replays', () => { }); }); -// ── Deck Storage ────────────────────────────────────────────────────────────── describe('Deck Storage', () => { it('BACKEND_DECKS → sets backendDecks', () => { @@ -645,7 +664,6 @@ describe('Deck Storage', () => { }); }); -// ── GAMES_OF_USER ───────────────────────────────────────────────────────────── describe('GAMES_OF_USER', () => { it('stores normalized games keyed by userName and gameId', () => { diff --git a/webclient/src/store/server/server.reducer.ts b/webclient/src/store/server/server.reducer.ts index a7e7682fa..1aa7efc8b 100644 --- a/webclient/src/store/server/server.reducer.ts +++ b/webclient/src/store/server/server.reducer.ts @@ -1,11 +1,14 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { App, Data, Enriched } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { create } from '@bufbuild/protobuf'; import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common'; import { ServerState, ServerStateStatus } from './server.interfaces'; +export const MAX_USER_MESSAGES = 1000; + function splitPath(path: string): string[] { return path ? path.split('/') : []; } @@ -71,7 +74,7 @@ const initialState: ServerState = { status: { connectionAttemptMade: false, - state: App.StatusEnum.DISCONNECTED, + state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, info: { @@ -172,17 +175,20 @@ export const serverSlice = createSlice({ updateStatus: (state, action: PayloadAction<{ status: Pick }>) => { const { status } = action.payload; - state.status = { ...state.status, ...status }; + state.status.state = status.state; + state.status.description = status.description; - if (status.state === App.StatusEnum.DISCONNECTED) { + if (status.state === WebsocketTypes.StatusEnum.DISCONNECTED) { state.status.connectionAttemptMade = false; } }, updateUser: (state, action: PayloadAction<{ user: Partial }>) => { - state.user = state.user - ? { ...state.user, ...action.payload.user } as Data.ServerInfo_User - : action.payload.user as Data.ServerInfo_User; + if (state.user) { + Object.assign(state.user, action.payload.user); + } else { + state.user = action.payload.user as Data.ServerInfo_User; + } }, updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => { @@ -203,7 +209,7 @@ export const serverSlice = createSlice({ }, viewLogs: (state, action: PayloadAction<{ logs: Data.ServerInfo_ChatMessage[] }>) => { - state.logs = { ...normalizeLogs(action.payload.logs) }; + state.logs = normalizeLogs(action.payload.logs); }, clearLogs: (state) => { @@ -211,11 +217,18 @@ export const serverSlice = createSlice({ }, userMessage: (state, action: PayloadAction<{ messageData: Data.Event_UserMessage }>) => { + if (!state.user) { + return; + } const { senderName, receiverName } = action.payload.messageData; - const userName = state.user!.name === senderName ? receiverName : senderName; + const userName = state.user.name === senderName ? receiverName : senderName; if (!state.messages[userName]) { state.messages[userName] = []; } + const msgs = state.messages[userName]; + if (msgs.length >= MAX_USER_MESSAGES) { + state.messages[userName] = msgs.slice(msgs.length - MAX_USER_MESSAGES + 1); + } state.messages[userName].push(action.payload.messageData); }, @@ -273,7 +286,7 @@ export const serverSlice = createSlice({ newLevel = shouldBeJudge ? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsJudge) : (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsJudge); - state.users[userName] = { ...user, userLevel: newLevel }; + user.userLevel = newLevel; }, replayList: (state, action: PayloadAction<{ matchList: Data.ServerInfo_ReplayMatch[] }>) => { @@ -295,7 +308,7 @@ export const serverSlice = createSlice({ if (!existing) { return; } - state.replays[gameId] = { ...existing, doNotHide }; + existing.doNotHide = doNotHide; }, replayDeleteMatch: (state, action: PayloadAction<{ gameId: number }>) => { @@ -379,39 +392,43 @@ export const serverSlice = createSlice({ }, accountEditChanged: (state, action: PayloadAction<{ user: Partial }>) => { - state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User; + if (state.user) { + Object.assign(state.user, action.payload.user); + } }, accountImageChanged: (state, action: PayloadAction<{ user: Partial }>) => { - state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User; + if (state.user) { + Object.assign(state.user, action.payload.user); + } }, // Signal-only action types — no state mutation, defined so type strings are generated - accountAwaitingActivation: (_state, _action: PayloadAction) => {}, - accountActivationFailed: (_state, _action: PayloadAction) => {}, - accountActivationSuccess: (_state, _action: PayloadAction) => {}, - loginSuccessful: (_state, _action: PayloadAction) => {}, - loginFailed: (_state, _action: PayloadAction) => {}, - connectionFailed: (_state, _action: PayloadAction) => {}, - testConnectionSuccessful: (_state, _action: PayloadAction) => {}, - testConnectionFailed: (_state, _action: PayloadAction) => {}, - registrationRequiresEmail: (_state, _action: PayloadAction) => {}, - registrationSuccess: (_state, _action: PayloadAction) => {}, - registrationEmailError: (_state, _action: PayloadAction) => {}, - registrationPasswordError: (_state, _action: PayloadAction) => {}, - registrationUserNameError: (_state, _action: PayloadAction) => {}, - resetPassword: (_state, _action: PayloadAction) => {}, - resetPasswordFailed: (_state, _action: PayloadAction) => {}, - resetPasswordChallenge: (_state, _action: PayloadAction) => {}, - resetPasswordSuccess: (_state, _action: PayloadAction) => {}, - reloadConfig: (_state, _action: PayloadAction) => {}, - shutdownServer: (_state, _action: PayloadAction) => {}, - updateServerMessage: (_state, _action: PayloadAction) => {}, - accountPasswordChange: (_state, _action: PayloadAction) => {}, - addToList: (_state, _action: PayloadAction) => {}, - removeFromList: (_state, _action: PayloadAction) => {}, - grantReplayAccess: (_state, _action: PayloadAction) => {}, - forceActivateUser: (_state, _action: PayloadAction) => {}, + accountAwaitingActivation: (_state, _action: PayloadAction<{ options: WebsocketTypes.PendingActivationContext }>) => {}, + accountActivationFailed: (_state) => {}, + accountActivationSuccess: (_state) => {}, + loginSuccessful: (_state, _action: PayloadAction<{ options: WebsocketTypes.LoginSuccessContext }>) => {}, + loginFailed: (_state) => {}, + connectionFailed: (_state) => {}, + testConnectionSuccessful: (_state) => {}, + testConnectionFailed: (_state) => {}, + registrationRequiresEmail: (_state) => {}, + registrationSuccess: (_state) => {}, + registrationEmailError: (_state, _action: PayloadAction<{ error: string }>) => {}, + registrationPasswordError: (_state, _action: PayloadAction<{ error: string }>) => {}, + registrationUserNameError: (_state, _action: PayloadAction<{ error: string }>) => {}, + resetPassword: (_state) => {}, + resetPasswordFailed: (_state) => {}, + resetPasswordChallenge: (_state) => {}, + resetPasswordSuccess: (_state) => {}, + reloadConfig: (_state) => {}, + shutdownServer: (_state) => {}, + updateServerMessage: (_state) => {}, + accountPasswordChange: (_state) => {}, + addToList: (_state, _action: PayloadAction<{ list: string; userName: string }>) => {}, + removeFromList: (_state, _action: PayloadAction<{ list: string; userName: string }>) => {}, + grantReplayAccess: (_state, _action: PayloadAction<{ replayId: number; moderatorName: string }>) => {}, + forceActivateUser: (_state, _action: PayloadAction<{ usernameToActivate: string; moderatorName: string }>) => {}, }, }); diff --git a/webclient/src/store/server/server.selectors.spec.ts b/webclient/src/store/server/server.selectors.spec.ts index 8d9a75097..df04e38bd 100644 --- a/webclient/src/store/server/server.selectors.spec.ts +++ b/webclient/src/store/server/server.selectors.spec.ts @@ -6,7 +6,8 @@ import { makeServerState, makeUser, } from './__mocks__/server-fixtures'; -import { App, Data } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; function rootState(server: ServerState) { return { server }; @@ -34,17 +35,23 @@ describe('Selectors', () => { }); it('getDescription → returns status.description', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.CONNECTED, description: 'ok' } }); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.CONNECTED, description: 'ok' }, + }); expect(Selectors.getDescription(rootState(state))).toBe('ok'); }); it('getState → returns status.state', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.LOGGED_IN, description: null } }); - expect(Selectors.getState(rootState(state))).toBe(App.StatusEnum.LOGGED_IN); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null }, + }); + expect(Selectors.getState(rootState(state))).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); }); it('getConnectionAttemptMade → returns status.connectionAttemptMade', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, + }); expect(Selectors.getConnectionAttemptMade(rootState(state))).toBe(true); }); @@ -150,20 +157,25 @@ describe('Selectors', () => { expect(Selectors.getRegistrationError(rootState(state))).toBe('bad input'); }); - // ── derived selectors (createSelector) ────────────────────────────── it('getIsConnected → true when state is LOGGED_IN', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null }, + }); expect(Selectors.getIsConnected(rootState(state))).toBe(true); }); it('getIsConnected → false when state is CONNECTED', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.CONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.CONNECTED, description: null }, + }); expect(Selectors.getIsConnected(rootState(state))).toBe(false); }); it('getIsConnected → false when state is DISCONNECTED', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, + }); expect(Selectors.getIsConnected(rootState(state))).toBe(false); }); @@ -186,10 +198,11 @@ describe('Selectors', () => { expect(Selectors.getIsUserModerator(rootState(state))).toBe(false); }); - // ── createSelector reference stability ────────────────────────────── it('getIsConnected → returns same value reference for identical state', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null }, + }); const root = rootState(state); const a = Selectors.getIsConnected(root); const b = Selectors.getIsConnected(root); diff --git a/webclient/src/store/server/server.selectors.ts b/webclient/src/store/server/server.selectors.ts index 535f3c43e..fb681bb90 100644 --- a/webclient/src/store/server/server.selectors.ts +++ b/webclient/src/store/server/server.selectors.ts @@ -1,5 +1,6 @@ import { createSelector } from '@reduxjs/toolkit'; -import { App, Data } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { SortUtil } from '../common'; import { ServerState } from './server.interfaces'; @@ -23,7 +24,7 @@ export const Selectors = { /** True when the server status has reached LOGGED_IN. */ getIsConnected: createSelector( [({ server }: State) => server.status.state], - (state): boolean => state === App.StatusEnum.LOGGED_IN + (state): boolean => state === WebsocketTypes.StatusEnum.LOGGED_IN ), /** True when the currently logged-in user has the IsModerator level flag. */ diff --git a/webclient/src/store/store.ts b/webclient/src/store/store.ts index 997d97661..b5261b782 100644 --- a/webclient/src/store/store.ts +++ b/webclient/src/store/store.ts @@ -3,11 +3,8 @@ import { isMessage } from '@bufbuild/protobuf'; import { useDispatch, useSelector } from 'react-redux'; import rootReducer from './rootReducer'; -// Protobuf-es v2 messages are already plain objects (no class prototype, unlike v1). -// They carry $typeName (string, identifies the message) and $unknown (binary unknown -// fields) — both are serializable and harmless in Redux state. No conversion needed. -// Fields may include Uint8Array (bytes) and BigInt (int64/uint64), which fail Redux -// Toolkit’s default serializable check, so we extend it to accept these types. +// Protobuf-es v2 messages are plain objects with $typeName/$unknown siblings; +// bytes fields are Uint8Array and int64/uint64 are BigInt. All four pass through. function isSerializable(value: unknown): boolean { return isPlain(value) || isMessage(value) || value instanceof Uint8Array || typeof value === 'bigint'; } diff --git a/webclient/src/types/app.ts b/webclient/src/types/app.ts index 2ec7c2a68..1921e8a44 100644 --- a/webclient/src/types/app.ts +++ b/webclient/src/types/app.ts @@ -1,5 +1,5 @@ export * from './cards'; -export * from './constants'; +export * from './regex-patterns'; export * from './countries'; export * from './languages'; export * from './routes'; diff --git a/webclient/src/types/countries.ts b/webclient/src/types/countries.ts index 0289b63ad..edc525877 100644 --- a/webclient/src/types/countries.ts +++ b/webclient/src/types/countries.ts @@ -250,4 +250,6 @@ export const countryCodes = [ 'XK', 'ZM', 'ZW', -]; +] as const; + +export type CountryCode = typeof countryCodes[number]; diff --git a/webclient/src/types/enriched.ts b/webclient/src/types/enriched.ts index b111c0e5e..cc3de313c 100644 --- a/webclient/src/types/enriched.ts +++ b/webclient/src/types/enriched.ts @@ -1,6 +1,5 @@ import type { Event_RoomSay, - GameEventContext, ServerInfo_Arrow, ServerInfo_Card, ServerInfo_ChatMessage, @@ -11,30 +10,21 @@ import type { ServerInfo_User, } from '@app/generated'; -// ── Domain model types (composition: raw proto + client-side fields) ────────── -// -// `info` holds the proto snapshot verbatim. Normalized/client-only fields -// live as siblings. For `Room`, the repeated collections on `info` -// (gameList, userList, gametypeList) are the *wire snapshot* from the last -// full update — they become stale after subsequent events. Always read from -// the normalized `games`, `users`, and `gametypeMap` fields. +// @critical `info` is the wire snapshot; repeated collections on it go stale. Read normalized siblings. +// See .github/instructions/webclient.instructions.md#data-structure-invariants. export interface GametypeMap { [index: number]: string } -/** Room directory listing — composition of raw proto with normalized collections. */ export interface Room { info: ServerInfo_Room; gametypeMap: GametypeMap; - /** Server-determined display order from the UPDATE_ROOMS sequence. */ order: number; games: { [gameId: number]: Game }; users: { [userName: string]: ServerInfo_User }; } -/** Room directory game listing — composition of raw proto with cached gameType. */ export interface Game { info: ServerInfo_Game; - /** Cached display string resolved from the owning room's gametypeMap at ingest. */ gameType: string; } @@ -42,27 +32,17 @@ export type Message = Event_RoomSay & { timeReceived: number; }; -// ── Active game runtime state (game slice) ─────────────────────────────────── -// -// Composition pattern: the raw proto from Event_GameJoined is stored verbatim -// on `info`. Fields that evolve via in-game events live at the top level. -// -// Convention: `info` is the wire snapshot taken at join time. Fields with a -// proto twin (e.g. `started`) diverge after the first event update — always -// read the top-level field for "current value"; `info.*` is the initial -// server snapshot only. - +// @critical `info` = wire snapshot at join time; top-level twins hold live values updated by game events. +// See .github/instructions/webclient.instructions.md#data-structure-invariants. export interface GameEntry { info: ServerInfo_Game; - // From the Event_GameJoined wrapper (not on ServerInfo_Game itself). hostId: number; localPlayerId: number; spectator: boolean; judge: boolean; resuming: boolean; - // Client-tracked runtime state, updated by game events. started: boolean; activePlayerId: number; activePhase: number; @@ -73,32 +53,22 @@ export interface GameEntry { messages: GameMessage[]; } -/** Normalized from ServerInfo_Player — keyed collections for O(1) lookup. */ export interface PlayerEntry { properties: ServerInfo_PlayerProperties; deckList: string; - /** Zones keyed by zone name (e.g. "hand", "deck", "table"). */ zones: { [zoneName: string]: ZoneEntry }; - /** Player-level counters (e.g. life) keyed by counter id. */ counters: { [counterId: number]: ServerInfo_Counter }; - /** Arrows keyed by arrow id. */ arrows: { [arrowId: number]: ServerInfo_Arrow }; } -/** - * Normalized from ServerInfo_Zone — cards indexed by id for O(1) mutation, - * with `order` preserving display sequence. Iterate via `order.map(id => byId[id])`. - */ export interface ZoneEntry { name: string; /** ZoneType enum value (0=Private, 1=Public, 2=Hidden). */ type: number; withCoords: boolean; - /** Authoritative card count. For hidden zones this may exceed `order.length`. */ + /** Authoritative count; for hidden zones this may exceed `order.length`. */ cardCount: number; - /** Card ids in display order. */ order: number[]; - /** Card lookup by id. */ byId: { [cardId: number]: ServerInfo_Card }; alwaysRevealTopCard: boolean; alwaysLookAtTopCard: boolean; @@ -110,56 +80,8 @@ export interface GameMessage { timeReceived: number; } -/** - * Passed to every game event handler alongside the event payload. - * Contains per-container metadata from GameEventContainer. - * Not stored in Redux — transient routing metadata only. - */ -export interface GameEventMeta { - gameId: number; - playerId: number; - /** Raw protobuf GameEventContext object. Not stored in Redux. */ - context: GameEventContext | null; - secondsElapsed: number; - /** Proto type is uint32. Non-zero means the action was forced by a judge. */ - forcedByJudge: number; -} - export interface LogGroups { room: ServerInfo_ChatMessage[]; game: ServerInfo_ChatMessage[]; chat: ServerInfo_ChatMessage[]; } - -// ── Connect options (re-exported from @app/websocket) ──────────────────────── -// Source of truth lives in src/websocket/connectOptions.ts. Re-exported here -// so UI code can use the Enriched.* namespace without importing @app/websocket. - -export type { - LoginConnectOptions, - RegisterConnectOptions, - ActivateConnectOptions, - PasswordResetRequestConnectOptions, - PasswordResetChallengeConnectOptions, - PasswordResetConnectOptions, - TestConnectionOptions, - WebSocketConnectOptions, -} from '@app/websocket'; - -/** - * Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the - * activation dialog can resubmit against the same host/user without re-entering them. - */ -export interface PendingActivationContext { - host: string; - port: string; - userName: string; -} - -/** - * Payload for the LOGIN_SUCCESSFUL signal. Only carries what the UI needs to - * persist into the selected host record (hashedPassword for "remember me"). - */ -export interface LoginSuccessContext { - hashedPassword?: string; -} diff --git a/webclient/src/types/constants.spec.ts b/webclient/src/types/regex-patterns.spec.ts similarity index 98% rename from webclient/src/types/constants.spec.ts rename to webclient/src/types/regex-patterns.spec.ts index d324867dc..c34f8f3fb 100644 --- a/webclient/src/types/constants.spec.ts +++ b/webclient/src/types/regex-patterns.spec.ts @@ -2,7 +2,7 @@ import { URL_REGEX, MESSAGE_SENDER_REGEX, MENTION_REGEX, -} from './constants'; +} from './regex-patterns'; describe('RegEx', () => { describe('URL_REGEX', () => { diff --git a/webclient/src/types/constants.ts b/webclient/src/types/regex-patterns.ts similarity index 100% rename from webclient/src/types/constants.ts rename to webclient/src/types/regex-patterns.ts diff --git a/webclient/src/types/server.ts b/webclient/src/types/server.ts index 19135bc8f..4b23cc02d 100644 --- a/webclient/src/types/server.ts +++ b/webclient/src/types/server.ts @@ -1,11 +1,3 @@ -export { StatusEnum, WebSocketConnectReason } from '@app/websocket'; -import type { StatusEnum } from '@app/websocket'; - -export interface ServerStatus { - status: StatusEnum; - description: string; -} - export class Host { id?: number; name: string; @@ -19,59 +11,3 @@ export class Host { hashedPassword?: string; remember?: boolean; } - -export const DefaultHosts: Host[] = [ - { - name: 'Chickatrice', - host: 'mtg.chickatrice.net', - port: '443', - localPort: '4748', - editable: false, - }, - { - name: 'Rooster', - host: 'server.cockatrice.us/servatrice', - port: '4748', - localHost: 'server.cockatrice.us', - editable: false, - }, - { - name: 'Rooster Beta', - host: 'beta.cockatrice.us/servatrice', - port: '4748', - localHost: 'beta.cockatrice.us', - editable: false, - }, - { - name: 'Tetrarch', - host: 'mtg.tetrarch.co/servatrice', - port: '443', - editable: false, - }, -]; - -export const getHostPort = (host: Host): { host: string, port: string } => { - const isLocal = window.location.hostname === 'localhost'; - - if (!host) { - return { - host: '', - port: '' - }; - } - - return { - host: !isLocal ? host.host : host.localHost || host.host, - port: !isLocal ? host.port : host.localPort || host.port, - } -}; - -export enum KnownHost { - ROOSTER = 'Rooster', - TETRARCH = 'Tetrarch', -} - -export const KnownHosts = { - [KnownHost.ROOSTER]: { port: 4748, host: 'server.cockatrice.us', }, - [KnownHost.TETRARCH]: { port: 443, host: 'mtg.tetrarch.co/servatrice' }, -} diff --git a/webclient/src/websocket/WebClient.spec.ts b/webclient/src/websocket/WebClient.spec.ts index 2fab4d695..d2fdb9b5e 100644 --- a/webclient/src/websocket/WebClient.spec.ts +++ b/webclient/src/websocket/WebClient.spec.ts @@ -29,13 +29,14 @@ vi.mock('./services/ProtobufService', () => ({ import { WebClient } from './WebClient'; import { WebSocketService } from './services/WebSocketService'; import { ProtobufService } from './services/ProtobufService'; -import { StatusEnum } from './interfaces/StatusEnum'; +import { StatusEnum } from './types/StatusEnum'; import { Subject } from 'rxjs'; import { Mock } from 'vitest'; import { SocketTransport } from './services/ProtobufService'; import { WebSocketServiceConfig } from './services/WebSocketService'; -import type { IWebClientResponse, IWebClientRequest } from './interfaces'; -import type { ConnectTarget } from './interfaces/WebClientConfig'; +import type { IWebClientResponse } from './types/WebClientResponse'; +import type { IWebClientRequest } from './types/WebClientRequest'; +import type { ConnectTarget } from './types/WebClientConfig'; import { installMockWebSocket } from './__mocks__/helpers'; function makeMockResponse(): IWebClientResponse { diff --git a/webclient/src/websocket/WebClient.ts b/webclient/src/websocket/WebClient.ts index ddd197ce6..ec628440e 100644 --- a/webclient/src/websocket/WebClient.ts +++ b/webclient/src/websocket/WebClient.ts @@ -1,11 +1,9 @@ import { ping } from './commands/session'; import { CLIENT_OPTIONS } from './config'; -import type { - ConnectTarget, - IWebClientRequest, - IWebClientResponse, -} from './interfaces'; -import { StatusEnum } from './interfaces'; +import type { ConnectTarget } from './types/WebClientConfig'; +import type { IWebClientRequest } from './types/WebClientRequest'; +import type { IWebClientResponse } from './types/WebClientResponse'; +import { StatusEnum } from './types/StatusEnum'; import { ProtobufService } from './services/ProtobufService'; import { WebSocketService } from './services/WebSocketService'; diff --git a/webclient/src/websocket/__mocks__/WebClient.ts b/webclient/src/websocket/__mocks__/WebClient.ts index 1219c3466..2485830e0 100644 --- a/webclient/src/websocket/__mocks__/WebClient.ts +++ b/webclient/src/websocket/__mocks__/WebClient.ts @@ -17,9 +17,6 @@ * property, not a getter that throws. */ -// --------------------------------------------------------------------------- -// response.session (ISessionResponse) -// --------------------------------------------------------------------------- const session = { initialized: vi.fn(), connectionAttempted: vi.fn(), @@ -80,9 +77,6 @@ const session = { replayDownloaded: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.room (IRoomResponse) -// --------------------------------------------------------------------------- const room = { clearStore: vi.fn(), joinRoom: vi.fn(), @@ -97,9 +91,6 @@ const room = { joinedGame: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.game (IGameResponse) -// --------------------------------------------------------------------------- const game = { clearStore: vi.fn(), gameStateChanged: vi.fn(), @@ -133,9 +124,6 @@ const game = { zonePropertiesChanged: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.admin (IAdminResponse) -// --------------------------------------------------------------------------- const admin = { adjustMod: vi.fn(), reloadConfig: vi.fn(), @@ -143,9 +131,6 @@ const admin = { updateServerMessage: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.moderator (IModeratorResponse) -// --------------------------------------------------------------------------- const moderator = { banFromServer: vi.fn(), banHistory: vi.fn(), @@ -159,9 +144,6 @@ const moderator = { updateAdminNotes: vi.fn(), }; -// --------------------------------------------------------------------------- -// Exported mock — replaces the real WebClient module for all consumers. -// --------------------------------------------------------------------------- export const WebClient = { _instance: null as any, instance: { diff --git a/webclient/src/websocket/__mocks__/helpers.ts b/webclient/src/websocket/__mocks__/helpers.ts index c76fd9f17..7ef2ed718 100644 --- a/webclient/src/websocket/__mocks__/helpers.ts +++ b/webclient/src/websocket/__mocks__/helpers.ts @@ -1,27 +1,6 @@ /** * Shared mock factories for websocket layer unit tests. - * Import the helpers you need in each spec file via: - * import { makeMockWebSocket, useWebClientCleanup } from '../__mocks__/helpers'; */ -import { WebClient } from '../WebClient'; - -/** - * Resets the WebClient singleton to null. Call directly, or use - * `useWebClientCleanup()` to register automatic beforeEach/afterEach hooks. - */ -export function resetWebClientSingleton() { - (WebClient as unknown as { _instance: WebClient | null })._instance = null; -} - -/** - * Registers beforeEach/afterEach hooks that reset the WebClient singleton. - * Call at describe-level or file-level in any spec that mocks WebClient. - * Prevents isolate:false singleton leakage between spec files. - */ -export function useWebClientCleanup() { - beforeEach(() => resetWebClientSingleton()); - afterEach(() => resetWebClientSingleton()); -} /** Builds a mock WebSocket instance */ export function makeMockWebSocketInstance() { diff --git a/webclient/src/websocket/commands/admin/adminCommands.spec.ts b/webclient/src/websocket/commands/admin/adminCommands.spec.ts index 51764a627..08db9e409 100644 --- a/webclient/src/websocket/commands/admin/adminCommands.spec.ts +++ b/webclient/src/websocket/commands/admin/adminCommands.spec.ts @@ -20,9 +20,6 @@ const { invokeOnSuccess } = makeCallbackHelpers( 2 ); -// ---------------------------------------------------------------- -// adjustMod -// ---------------------------------------------------------------- describe('adjustMod', () => { it('calls sendAdminCommand with Command_AdjustMod extension and fields', () => { @@ -41,9 +38,6 @@ describe('adjustMod', () => { }); }); -// ---------------------------------------------------------------- -// reloadConfig -// ---------------------------------------------------------------- describe('reloadConfig', () => { it('calls sendAdminCommand with Command_ReloadConfig extension', () => { @@ -62,9 +56,6 @@ describe('reloadConfig', () => { }); }); -// ---------------------------------------------------------------- -// shutdownServer -// ---------------------------------------------------------------- describe('shutdownServer', () => { it('calls sendAdminCommand with Command_ShutdownServer extension and fields', () => { @@ -83,9 +74,6 @@ describe('shutdownServer', () => { }); }); -// ---------------------------------------------------------------- -// updateServerMessage -// ---------------------------------------------------------------- describe('updateServerMessage', () => { it('calls sendAdminCommand with Command_UpdateServerMessage extension', () => { diff --git a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts index 69ba240fd..4dd7fcf76 100644 --- a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts +++ b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts @@ -39,9 +39,6 @@ const { invokeOnSuccess } = makeCallbackHelpers( 2 ); -// ---------------------------------------------------------------- -// banFromServer -// ---------------------------------------------------------------- describe('banFromServer', () => { it('calls sendModeratorCommand with Command_BanFromServer', () => { @@ -60,9 +57,6 @@ describe('banFromServer', () => { }); }); -// ---------------------------------------------------------------- -// forceActivateUser -// ---------------------------------------------------------------- describe('forceActivateUser', () => { it('calls sendModeratorCommand with Command_ForceActivateUser', () => { @@ -79,9 +73,6 @@ describe('forceActivateUser', () => { }); }); -// ---------------------------------------------------------------- -// getAdminNotes -// ---------------------------------------------------------------- describe('getAdminNotes', () => { it('calls sendModeratorCommand with Command_GetAdminNotes', () => { @@ -101,9 +92,6 @@ describe('getAdminNotes', () => { }); }); -// ---------------------------------------------------------------- -// getBanHistory -// ---------------------------------------------------------------- describe('getBanHistory', () => { it('calls sendModeratorCommand with Command_GetBanHistory', () => { @@ -123,9 +111,6 @@ describe('getBanHistory', () => { }); }); -// ---------------------------------------------------------------- -// getWarnHistory -// ---------------------------------------------------------------- describe('getWarnHistory', () => { it('calls sendModeratorCommand with Command_GetWarnHistory', () => { @@ -145,9 +130,6 @@ describe('getWarnHistory', () => { }); }); -// ---------------------------------------------------------------- -// getWarnList -// ---------------------------------------------------------------- describe('getWarnList', () => { it('calls sendModeratorCommand with Command_GetWarnList', () => { @@ -167,9 +149,6 @@ describe('getWarnList', () => { }); }); -// ---------------------------------------------------------------- -// grantReplayAccess -// ---------------------------------------------------------------- describe('grantReplayAccess', () => { it('calls sendModeratorCommand with Command_GrantReplayAccess', () => { @@ -186,9 +165,6 @@ describe('grantReplayAccess', () => { }); }); -// ---------------------------------------------------------------- -// updateAdminNotes -// ---------------------------------------------------------------- describe('updateAdminNotes', () => { it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => { @@ -205,9 +181,6 @@ describe('updateAdminNotes', () => { }); }); -// ---------------------------------------------------------------- -// viewLogHistory -// ---------------------------------------------------------------- describe('viewLogHistory', () => { it('calls sendModeratorCommand with Command_ViewLogHistory', () => { @@ -229,9 +202,6 @@ describe('viewLogHistory', () => { }); }); -// ---------------------------------------------------------------- -// warnUser -// ---------------------------------------------------------------- describe('warnUser', () => { it('calls sendModeratorCommand with Command_WarnUser', () => { diff --git a/webclient/src/websocket/commands/room/roomCommands.spec.ts b/webclient/src/websocket/commands/room/roomCommands.spec.ts index 57dac994a..b748030a6 100644 --- a/webclient/src/websocket/commands/room/roomCommands.spec.ts +++ b/webclient/src/websocket/commands/room/roomCommands.spec.ts @@ -24,9 +24,6 @@ const { invokeOnSuccess } = makeCallbackHelpers( 3 ); -// ---------------------------------------------------------------- -// createGame -// ---------------------------------------------------------------- describe('createGame', () => { it('calls sendRoomCommand with Command_CreateGame', () => { @@ -43,9 +40,6 @@ describe('createGame', () => { }); }); -// ---------------------------------------------------------------- -// joinGame -// ---------------------------------------------------------------- describe('joinGame', () => { it('calls sendRoomCommand with Command_JoinGame', () => { @@ -62,9 +56,6 @@ describe('joinGame', () => { }); }); -// ---------------------------------------------------------------- -// leaveRoom -// ---------------------------------------------------------------- describe('leaveRoom', () => { it('calls sendRoomCommand with Command_LeaveRoom', () => { @@ -81,9 +72,6 @@ describe('leaveRoom', () => { }); }); -// ---------------------------------------------------------------- -// roomSay -// ---------------------------------------------------------------- describe('roomSay', () => { it('calls sendRoomCommand with trimmed message', () => { diff --git a/webclient/src/websocket/commands/session/activate.ts b/webclient/src/websocket/commands/session/activate.ts index 422ed937f..368e676f7 100644 --- a/webclient/src/websocket/commands/session/activate.ts +++ b/webclient/src/websocket/commands/session/activate.ts @@ -6,10 +6,10 @@ import { type ActivateParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { disconnect, login, updateStatus } from './'; export function activate(options: ConnectTarget & ActivateParams, password?: string, passwordSalt?: string): void { diff --git a/webclient/src/websocket/commands/session/connect.ts b/webclient/src/websocket/commands/session/connect.ts index 035f5a60a..d260ca8c2 100644 --- a/webclient/src/websocket/commands/session/connect.ts +++ b/webclient/src/websocket/commands/session/connect.ts @@ -1,5 +1,5 @@ import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; export function connect(target: ConnectTarget): void { WebClient.instance.connect(target); diff --git a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts index 7246580af..88b9da8e3 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts @@ -5,10 +5,10 @@ import { type ForgotPasswordChallengeParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { disconnect, updateStatus } from './'; export function forgotPasswordChallenge(options: ConnectTarget & ForgotPasswordChallengeParams): void { diff --git a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts index cf8246b30..8099072dc 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts @@ -6,10 +6,10 @@ import { type ForgotPasswordRequestParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { disconnect, updateStatus } from './'; export function forgotPasswordRequest(options: ConnectTarget & ForgotPasswordRequestParams): void { diff --git a/webclient/src/websocket/commands/session/forgotPasswordReset.ts b/webclient/src/websocket/commands/session/forgotPasswordReset.ts index 3acd946b7..74792c292 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordReset.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordReset.ts @@ -6,10 +6,10 @@ import { type ForgotPasswordResetParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { hashPassword } from '../../utils'; import { disconnect, updateStatus } from '.'; diff --git a/webclient/src/websocket/commands/session/login.ts b/webclient/src/websocket/commands/session/login.ts index 80fddfcc7..fc290ceda 100644 --- a/webclient/src/websocket/commands/session/login.ts +++ b/webclient/src/websocket/commands/session/login.ts @@ -8,10 +8,10 @@ import { type LoginParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { hashPassword } from '../../utils'; import { disconnect, diff --git a/webclient/src/websocket/commands/session/register.ts b/webclient/src/websocket/commands/session/register.ts index ec0b5ecfa..42eee407e 100644 --- a/webclient/src/websocket/commands/session/register.ts +++ b/webclient/src/websocket/commands/session/register.ts @@ -8,10 +8,10 @@ import { type RegisterParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { hashPassword } from '../../utils'; import { login, disconnect, updateStatus } from './'; diff --git a/webclient/src/websocket/commands/session/requestPasswordSalt.ts b/webclient/src/websocket/commands/session/requestPasswordSalt.ts index 73fed0aec..3c4e4ff35 100644 --- a/webclient/src/websocket/commands/session/requestPasswordSalt.ts +++ b/webclient/src/websocket/commands/session/requestPasswordSalt.ts @@ -7,10 +7,10 @@ import { type RequestPasswordSaltParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { updateStatus } from './'; export function requestPasswordSalt( diff --git a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts index 7f0b9e2c6..dda7b8c05 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts @@ -18,8 +18,16 @@ import { Mock } from 'vitest'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; import { WebClient } from '../../WebClient'; import * as SessionIndexMocks from './'; -import { App, Enriched } from '@app/types'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { + WebSocketConnectReason, + type LoginConnectOptions, + type RegisterConnectOptions, + type ActivateConnectOptions, + type PasswordResetRequestConnectOptions, + type PasswordResetChallengeConnectOptions, + type PasswordResetConnectOptions, +} from '../../types/ConnectOptions'; +import { StatusEnum } from '../../types/StatusEnum'; import { Command_Activate_ext, Command_ForgotPasswordChallenge_ext, @@ -56,50 +64,50 @@ const { invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpe ); const baseTransport = { host: 'h', port: '1' }; -const makeLoginOpts = (overrides: Partial = {}): Enriched.LoginConnectOptions => ({ +const makeLoginOpts = (overrides: Partial = {}): LoginConnectOptions => ({ ...baseTransport, userName: 'alice', - reason: App.WebSocketConnectReason.LOGIN, + reason: WebSocketConnectReason.LOGIN, ...overrides, }); const makeRegisterOpts = ( - overrides: Partial = {} -): Enriched.RegisterConnectOptions => ({ + overrides: Partial = {} +): RegisterConnectOptions => ({ ...baseTransport, userName: 'alice', password: 'pw', email: 'a@b.com', country: 'US', realName: 'Al', - reason: App.WebSocketConnectReason.REGISTER, + reason: WebSocketConnectReason.REGISTER, ...overrides, }); const makeActivateOpts = ( - overrides: Partial = {} -): Enriched.ActivateConnectOptions => ({ + overrides: Partial = {} +): ActivateConnectOptions => ({ ...baseTransport, userName: 'alice', token: 'tok', - reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT, + reason: WebSocketConnectReason.ACTIVATE_ACCOUNT, ...overrides, }); -const makeForgotRequestOpts = (): Enriched.PasswordResetRequestConnectOptions => ({ +const makeForgotRequestOpts = (): PasswordResetRequestConnectOptions => ({ ...baseTransport, userName: 'alice', - reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST, + reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST, }); -const makeForgotChallengeOpts = (): Enriched.PasswordResetChallengeConnectOptions => ({ +const makeForgotChallengeOpts = (): PasswordResetChallengeConnectOptions => ({ ...baseTransport, userName: 'alice', email: 'a@b.com', - reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE, + reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE, }); -const makeForgotResetOpts = (): Enriched.PasswordResetConnectOptions => ({ +const makeForgotResetOpts = (): PasswordResetConnectOptions => ({ ...baseTransport, userName: 'alice', token: 'tok', newPassword: 'newpw', - reason: App.WebSocketConnectReason.PASSWORD_RESET, + reason: WebSocketConnectReason.PASSWORD_RESET, }); @@ -109,9 +117,6 @@ beforeEach(() => { (passwordSaltSupported as Mock).mockReturnValue(0); }); -// ---------------------------------------------------------------- -// connect.ts -// ---------------------------------------------------------------- describe('connect', () => { it('calls WebClient.instance.connect with the target', () => { @@ -128,9 +133,6 @@ describe('testConnect', () => { }); }); -// ---------------------------------------------------------------- -// updateStatus.ts -// ---------------------------------------------------------------- describe('updateStatus', () => { it('calls WebClient.instance.response.session.updateStatus and WebClient.instance.updateStatus', () => { @@ -140,9 +142,6 @@ describe('updateStatus', () => { }); }); -// ---------------------------------------------------------------- -// login.ts -// ---------------------------------------------------------------- describe('login', () => { it('sends Command_Login with plain password when no salt', () => { @@ -194,7 +193,7 @@ describe('login', () => { }); it('onSuccess passes hashedPassword to loginSuccessful when salt is used', () => { - login({ host: 'h', port: '1', userName: 'alice', reason: App.WebSocketConnectReason.LOGIN }, 'pw', 'salt'); + login({ host: 'h', port: '1', userName: 'alice', reason: WebSocketConnectReason.LOGIN }, 'pw', 'salt'); const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } }; invokeOnSuccess(loginResp, { responseCode: 0 }); const calledWith = (WebClient.instance.response.session.loginSuccessful as Mock).mock.calls[0][0]; @@ -266,9 +265,6 @@ describe('login', () => { }); }); -// ---------------------------------------------------------------- -// register.ts -// ---------------------------------------------------------------- describe('register', () => { it('sends Command_Register with plain password when no salt', () => { @@ -371,9 +367,6 @@ describe('register', () => { }); }); -// ---------------------------------------------------------------- -// activate.ts -// ---------------------------------------------------------------- describe('activate', () => { it('sends Command_Activate with userName and token, not password', () => { @@ -405,9 +398,6 @@ describe('activate', () => { }); }); -// ---------------------------------------------------------------- -// forgotPasswordChallenge.ts -// ---------------------------------------------------------------- describe('forgotPasswordChallenge', () => { it('sends Command_ForgotPasswordChallenge', () => { @@ -432,9 +422,6 @@ describe('forgotPasswordChallenge', () => { }); }); -// ---------------------------------------------------------------- -// forgotPasswordRequest.ts -// ---------------------------------------------------------------- describe('forgotPasswordRequest', () => { it('sends Command_ForgotPasswordRequest', () => { @@ -470,9 +457,6 @@ describe('forgotPasswordRequest', () => { }); }); -// ---------------------------------------------------------------- -// forgotPasswordReset.ts -// ---------------------------------------------------------------- describe('forgotPasswordReset', () => { it('sends Command_ForgotPasswordReset with plain newPassword when no salt', () => { @@ -508,9 +492,6 @@ describe('forgotPasswordReset', () => { }); }); -// ---------------------------------------------------------------- -// requestPasswordSalt.ts -// ---------------------------------------------------------------- describe('requestPasswordSalt', () => { it('sends Command_RequestPasswordSalt', () => { diff --git a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts index 18c8be728..289ca227a 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts @@ -92,7 +92,6 @@ beforeEach(() => { (passwordSaltSupported as Mock).mockReturnValue(0); }); -// ---------------------------------------------------------------- describe('accountEdit', () => { it('sends Command_AccountEdit with correct params', () => { diff --git a/webclient/src/websocket/commands/session/updateStatus.ts b/webclient/src/websocket/commands/session/updateStatus.ts index 52cb9ccbc..596b72f21 100644 --- a/webclient/src/websocket/commands/session/updateStatus.ts +++ b/webclient/src/websocket/commands/session/updateStatus.ts @@ -1,4 +1,4 @@ -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { WebClient } from '../../WebClient'; export function updateStatus(status: StatusEnum, description: string): void { diff --git a/webclient/src/websocket/events/game/attachCard.ts b/webclient/src/websocket/events/game/attachCard.ts index a3671cb05..9de5c6114 100644 --- a/webclient/src/websocket/events/game/attachCard.ts +++ b/webclient/src/websocket/events/game/attachCard.ts @@ -1,5 +1,5 @@ import type { Event_AttachCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function attachCard(data: Event_AttachCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/changeZoneProperties.ts b/webclient/src/websocket/events/game/changeZoneProperties.ts index 88d7f95f1..8afff45bf 100644 --- a/webclient/src/websocket/events/game/changeZoneProperties.ts +++ b/webclient/src/websocket/events/game/changeZoneProperties.ts @@ -1,5 +1,5 @@ import type { Event_ChangeZoneProperties } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function changeZoneProperties(data: Event_ChangeZoneProperties, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createArrow.ts b/webclient/src/websocket/events/game/createArrow.ts index 3f39fa064..ef2443365 100644 --- a/webclient/src/websocket/events/game/createArrow.ts +++ b/webclient/src/websocket/events/game/createArrow.ts @@ -1,5 +1,5 @@ import type { Event_CreateArrow } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createArrow(data: Event_CreateArrow, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createCounter.ts b/webclient/src/websocket/events/game/createCounter.ts index db9ca6086..54abcd0cf 100644 --- a/webclient/src/websocket/events/game/createCounter.ts +++ b/webclient/src/websocket/events/game/createCounter.ts @@ -1,5 +1,5 @@ import type { Event_CreateCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createCounter(data: Event_CreateCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createToken.ts b/webclient/src/websocket/events/game/createToken.ts index 1542d4418..9c406da16 100644 --- a/webclient/src/websocket/events/game/createToken.ts +++ b/webclient/src/websocket/events/game/createToken.ts @@ -1,5 +1,5 @@ import type { Event_CreateToken } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createToken(data: Event_CreateToken, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/delCounter.ts b/webclient/src/websocket/events/game/delCounter.ts index 1a128b0ad..bf4231a93 100644 --- a/webclient/src/websocket/events/game/delCounter.ts +++ b/webclient/src/websocket/events/game/delCounter.ts @@ -1,5 +1,5 @@ import type { Event_DelCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function delCounter(data: Event_DelCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/deleteArrow.ts b/webclient/src/websocket/events/game/deleteArrow.ts index df39f5d47..d3653313b 100644 --- a/webclient/src/websocket/events/game/deleteArrow.ts +++ b/webclient/src/websocket/events/game/deleteArrow.ts @@ -1,5 +1,5 @@ import type { Event_DeleteArrow } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function deleteArrow(data: Event_DeleteArrow, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/destroyCard.ts b/webclient/src/websocket/events/game/destroyCard.ts index 65cb87d38..de010fbfb 100644 --- a/webclient/src/websocket/events/game/destroyCard.ts +++ b/webclient/src/websocket/events/game/destroyCard.ts @@ -1,5 +1,5 @@ import type { Event_DestroyCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function destroyCard(data: Event_DestroyCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/drawCards.ts b/webclient/src/websocket/events/game/drawCards.ts index c9fed3078..c9ab5e26e 100644 --- a/webclient/src/websocket/events/game/drawCards.ts +++ b/webclient/src/websocket/events/game/drawCards.ts @@ -1,5 +1,5 @@ import type { Event_DrawCards } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function drawCards(data: Event_DrawCards, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/dumpZone.ts b/webclient/src/websocket/events/game/dumpZone.ts index 8302877ef..24f2f3e72 100644 --- a/webclient/src/websocket/events/game/dumpZone.ts +++ b/webclient/src/websocket/events/game/dumpZone.ts @@ -1,5 +1,5 @@ import type { Event_DumpZone } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function dumpZone(data: Event_DumpZone, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/flipCard.ts b/webclient/src/websocket/events/game/flipCard.ts index 1c4e74cd3..4b57e89c3 100644 --- a/webclient/src/websocket/events/game/flipCard.ts +++ b/webclient/src/websocket/events/game/flipCard.ts @@ -1,5 +1,5 @@ import type { Event_FlipCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function flipCard(data: Event_FlipCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameClosed.ts b/webclient/src/websocket/events/game/gameClosed.ts index 73fc445e2..40168cf0d 100644 --- a/webclient/src/websocket/events/game/gameClosed.ts +++ b/webclient/src/websocket/events/game/gameClosed.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameClosed(_data: {}, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameEvents.spec.ts b/webclient/src/websocket/events/game/gameEvents.spec.ts index 1276f1724..a5756bb34 100644 --- a/webclient/src/websocket/events/game/gameEvents.spec.ts +++ b/webclient/src/websocket/events/game/gameEvents.spec.ts @@ -115,10 +115,10 @@ describe('playerPropertiesChanged event', () => { }); describe('gameSay event', () => { - it('delegates to WebClient.instance.response.game.gameSay with gameId, playerId, message', () => { + it('delegates to WebClient.instance.response.game.gameSay with gameId, playerId, message, timeReceived', () => { const data = create(Event_GameSaySchema, { message: 'gg' }); gameSay(data, meta); - expect(WebClient.instance.response.game.gameSay).toHaveBeenCalledWith(5, 2, 'gg'); + expect(WebClient.instance.response.game.gameSay).toHaveBeenCalledWith(5, 2, 'gg', expect.any(Number)); }); }); diff --git a/webclient/src/websocket/events/game/gameHostChanged.ts b/webclient/src/websocket/events/game/gameHostChanged.ts index 2cc7e5064..da89fc34d 100644 --- a/webclient/src/websocket/events/game/gameHostChanged.ts +++ b/webclient/src/websocket/events/game/gameHostChanged.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; /** diff --git a/webclient/src/websocket/events/game/gameSay.ts b/webclient/src/websocket/events/game/gameSay.ts index 72a585fef..18a643e0f 100644 --- a/webclient/src/websocket/events/game/gameSay.ts +++ b/webclient/src/websocket/events/game/gameSay.ts @@ -1,7 +1,7 @@ import type { Event_GameSay } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameSay(data: Event_GameSay, meta: GameEventMeta): void { - WebClient.instance.response.game.gameSay(meta.gameId, meta.playerId, data.message); + WebClient.instance.response.game.gameSay(meta.gameId, meta.playerId, data.message, Date.now()); } diff --git a/webclient/src/websocket/events/game/gameStateChanged.ts b/webclient/src/websocket/events/game/gameStateChanged.ts index ff3d53fa3..cc5b95d7a 100644 --- a/webclient/src/websocket/events/game/gameStateChanged.ts +++ b/webclient/src/websocket/events/game/gameStateChanged.ts @@ -1,5 +1,5 @@ import type { Event_GameStateChanged } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameStateChanged(data: Event_GameStateChanged, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/index.ts b/webclient/src/websocket/events/game/index.ts index d19292e80..5798a1aaa 100644 --- a/webclient/src/websocket/events/game/index.ts +++ b/webclient/src/websocket/events/game/index.ts @@ -35,7 +35,7 @@ import { Event_ReverseTurn_ext, } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { attachCard } from './attachCard'; import { changeZoneProperties } from './changeZoneProperties'; diff --git a/webclient/src/websocket/events/game/joinGame.ts b/webclient/src/websocket/events/game/joinGame.ts index f0efd619b..b2ede9a4d 100644 --- a/webclient/src/websocket/events/game/joinGame.ts +++ b/webclient/src/websocket/events/game/joinGame.ts @@ -1,5 +1,5 @@ import type { Event_Join } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; diff --git a/webclient/src/websocket/events/game/kicked.ts b/webclient/src/websocket/events/game/kicked.ts index f63951dcc..ba7db0674 100644 --- a/webclient/src/websocket/events/game/kicked.ts +++ b/webclient/src/websocket/events/game/kicked.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function kicked(_data: {}, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/leaveGame.ts b/webclient/src/websocket/events/game/leaveGame.ts index 5d2df026e..738a34ce7 100644 --- a/webclient/src/websocket/events/game/leaveGame.ts +++ b/webclient/src/websocket/events/game/leaveGame.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function leaveGame(data: { reason: number }, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/moveCard.ts b/webclient/src/websocket/events/game/moveCard.ts index a553e727a..47910ad4e 100644 --- a/webclient/src/websocket/events/game/moveCard.ts +++ b/webclient/src/websocket/events/game/moveCard.ts @@ -1,5 +1,5 @@ import type { Event_MoveCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function moveCard(data: Event_MoveCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/playerPropertiesChanged.ts b/webclient/src/websocket/events/game/playerPropertiesChanged.ts index 073f5a408..d01a1578d 100644 --- a/webclient/src/websocket/events/game/playerPropertiesChanged.ts +++ b/webclient/src/websocket/events/game/playerPropertiesChanged.ts @@ -1,5 +1,5 @@ import type { Event_PlayerPropertiesChanged } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function playerPropertiesChanged(data: Event_PlayerPropertiesChanged, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/revealCards.ts b/webclient/src/websocket/events/game/revealCards.ts index 9eb08b82a..cce29c619 100644 --- a/webclient/src/websocket/events/game/revealCards.ts +++ b/webclient/src/websocket/events/game/revealCards.ts @@ -1,5 +1,5 @@ import type { Event_RevealCards } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function revealCards(data: Event_RevealCards, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/reverseTurn.ts b/webclient/src/websocket/events/game/reverseTurn.ts index ef953aba8..5340e5ae3 100644 --- a/webclient/src/websocket/events/game/reverseTurn.ts +++ b/webclient/src/websocket/events/game/reverseTurn.ts @@ -1,5 +1,5 @@ import type { Event_ReverseTurn } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function reverseTurn(data: Event_ReverseTurn, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/rollDie.ts b/webclient/src/websocket/events/game/rollDie.ts index ca5365144..da411fad4 100644 --- a/webclient/src/websocket/events/game/rollDie.ts +++ b/webclient/src/websocket/events/game/rollDie.ts @@ -1,5 +1,5 @@ import type { Event_RollDie } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function rollDie(data: Event_RollDie, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setActivePhase.ts b/webclient/src/websocket/events/game/setActivePhase.ts index 134651bdb..c7f92961c 100644 --- a/webclient/src/websocket/events/game/setActivePhase.ts +++ b/webclient/src/websocket/events/game/setActivePhase.ts @@ -1,5 +1,5 @@ import type { Event_SetActivePhase } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setActivePhase(data: Event_SetActivePhase, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setActivePlayer.ts b/webclient/src/websocket/events/game/setActivePlayer.ts index e84f44920..730f9614e 100644 --- a/webclient/src/websocket/events/game/setActivePlayer.ts +++ b/webclient/src/websocket/events/game/setActivePlayer.ts @@ -1,5 +1,5 @@ import type { Event_SetActivePlayer } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setActivePlayer(data: Event_SetActivePlayer, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCardAttr.ts b/webclient/src/websocket/events/game/setCardAttr.ts index 3733dc7b1..1ce1d63bc 100644 --- a/webclient/src/websocket/events/game/setCardAttr.ts +++ b/webclient/src/websocket/events/game/setCardAttr.ts @@ -1,5 +1,5 @@ import type { Event_SetCardAttr } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCardAttr(data: Event_SetCardAttr, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCardCounter.ts b/webclient/src/websocket/events/game/setCardCounter.ts index c96d50ac6..b78786f89 100644 --- a/webclient/src/websocket/events/game/setCardCounter.ts +++ b/webclient/src/websocket/events/game/setCardCounter.ts @@ -1,5 +1,5 @@ import type { Event_SetCardCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCardCounter(data: Event_SetCardCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCounter.ts b/webclient/src/websocket/events/game/setCounter.ts index 310fb9e28..c4cf794c2 100644 --- a/webclient/src/websocket/events/game/setCounter.ts +++ b/webclient/src/websocket/events/game/setCounter.ts @@ -1,5 +1,5 @@ import type { Event_SetCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCounter(data: Event_SetCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/shuffle.ts b/webclient/src/websocket/events/game/shuffle.ts index 9d1689472..2c404390d 100644 --- a/webclient/src/websocket/events/game/shuffle.ts +++ b/webclient/src/websocket/events/game/shuffle.ts @@ -1,5 +1,5 @@ import type { Event_Shuffle } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function shuffle(data: Event_Shuffle, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/session/connectionClosed.ts b/webclient/src/websocket/events/session/connectionClosed.ts index b3422172f..d292d5687 100644 --- a/webclient/src/websocket/events/session/connectionClosed.ts +++ b/webclient/src/websocket/events/session/connectionClosed.ts @@ -1,11 +1,10 @@ import { Event_ConnectionClosed_CloseReason, type Event_ConnectionClosed } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { updateStatus } from '../../commands/session'; export function connectionClosed({ reason, reasonStr, endTime }: Event_ConnectionClosed): void { let message: string; - // @TODO (5) if (reasonStr) { message = reasonStr; } else { diff --git a/webclient/src/websocket/events/session/serverIdentification.ts b/webclient/src/websocket/events/session/serverIdentification.ts index 6c7956f3f..57e999032 100644 --- a/webclient/src/websocket/events/session/serverIdentification.ts +++ b/webclient/src/websocket/events/session/serverIdentification.ts @@ -1,9 +1,9 @@ import type { Event_ServerIdentification } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { PROTOCOL_VERSION } from '../../config'; import { consumePendingOptions } from '../../utils/connectionState'; -import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; +import { WebSocketConnectReason } from '../../types/ConnectOptions'; import { generateSalt, passwordSaltSupported } from '../../utils'; import * as SessionCommands from '../../commands/session'; diff --git a/webclient/src/websocket/events/session/sessionEvents.spec.ts b/webclient/src/websocket/events/session/sessionEvents.spec.ts index c627a6856..7383a13d5 100644 --- a/webclient/src/websocket/events/session/sessionEvents.spec.ts +++ b/webclient/src/websocket/events/session/sessionEvents.spec.ts @@ -58,8 +58,8 @@ import * as Config from '../../config'; import * as SessionCmds from '../../commands/session'; import { consumePendingOptions } from '../../utils/connectionState'; import { passwordSaltSupported } from '../../utils'; -import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { WebSocketConnectReason } from '../../types/ConnectOptions'; +import { StatusEnum } from '../../types/StatusEnum'; import { Mock } from 'vitest'; import { gameJoined } from './gameJoined'; import { notifyUser } from './notifyUser'; @@ -78,9 +78,6 @@ import { serverIdentification } from './serverIdentification'; const ConfigMock = Config as { -readonly [K in keyof typeof Config]: (typeof Config)[K] }; -// ---------------------------------------------------------------- -// gameJoined -// ---------------------------------------------------------------- describe('gameJoined', () => { it('calls WebClient.instance.response.session.gameJoined', () => { @@ -90,9 +87,6 @@ describe('gameJoined', () => { }); }); -// ---------------------------------------------------------------- -// notifyUser -// ---------------------------------------------------------------- describe('notifyUser', () => { it('calls WebClient.instance.response.session.notifyUser', () => { @@ -102,9 +96,6 @@ describe('notifyUser', () => { }); }); -// ---------------------------------------------------------------- -// replayAdded -// ---------------------------------------------------------------- describe('replayAdded', () => { it('calls WebClient.instance.response.session.replayAdded with matchInfo', () => { @@ -116,9 +107,6 @@ describe('replayAdded', () => { }); }); -// ---------------------------------------------------------------- -// serverCompleteList -// ---------------------------------------------------------------- describe('serverCompleteList', () => { it('calls WebClient.instance.response.session.updateUsers and WebClient.instance.response.room.updateRooms', () => { @@ -129,9 +117,6 @@ describe('serverCompleteList', () => { }); }); -// ---------------------------------------------------------------- -// serverMessage -// ---------------------------------------------------------------- describe('serverMessage', () => { it('calls WebClient.instance.response.session.serverMessage with message', () => { @@ -140,9 +125,6 @@ describe('serverMessage', () => { }); }); -// ---------------------------------------------------------------- -// serverShutdown -// ---------------------------------------------------------------- describe('serverShutdown', () => { it('calls WebClient.instance.response.session.serverShutdown', () => { @@ -152,9 +134,6 @@ describe('serverShutdown', () => { }); }); -// ---------------------------------------------------------------- -// userJoined -// ---------------------------------------------------------------- describe('userJoined', () => { it('calls WebClient.instance.response.session.userJoined with userInfo', () => { @@ -166,9 +145,6 @@ describe('userJoined', () => { }); }); -// ---------------------------------------------------------------- -// userLeft -// ---------------------------------------------------------------- describe('userLeft', () => { it('calls WebClient.instance.response.session.userLeft with name', () => { @@ -177,9 +153,6 @@ describe('userLeft', () => { }); }); -// ---------------------------------------------------------------- -// userMessage -// ---------------------------------------------------------------- describe('userMessage', () => { it('calls WebClient.instance.response.session.userMessage', () => { @@ -189,9 +162,6 @@ describe('userMessage', () => { }); }); -// ---------------------------------------------------------------- -// addToList -// ---------------------------------------------------------------- describe('addToList', () => { let logSpy: ReturnType; beforeEach(() => { @@ -225,9 +195,6 @@ describe('addToList', () => { }); }); -// ---------------------------------------------------------------- -// removeFromList -// ---------------------------------------------------------------- describe('removeFromList', () => { it('buddy list → removeFromBuddyList', () => { @@ -248,9 +215,6 @@ describe('removeFromList', () => { }); }); -// ---------------------------------------------------------------- -// listRooms -// ---------------------------------------------------------------- describe('listRooms', () => { it('calls WebClient.instance.response.room.updateRooms', () => { @@ -279,9 +243,6 @@ describe('listRooms', () => { }); }); -// ---------------------------------------------------------------- -// connectionClosed -// ---------------------------------------------------------------- describe('connectionClosed', () => { it('uses reasonStr when provided', () => { @@ -371,9 +332,6 @@ describe('connectionClosed', () => { }); }); -// ---------------------------------------------------------------- -// serverIdentification -// ---------------------------------------------------------------- describe('serverIdentification', () => { const makeInfo = (overrides: Record = {}) => create(Event_ServerIdentificationSchema, { diff --git a/webclient/src/websocket/index.ts b/webclient/src/websocket/index.ts index 9d0cd9481..4afca0522 100644 --- a/webclient/src/websocket/index.ts +++ b/webclient/src/websocket/index.ts @@ -1,32 +1,10 @@ export * from './commands'; -export * from './interfaces'; export { WebClient } from './WebClient'; -export { StatusEnum } from './interfaces/StatusEnum'; -export type { WebClientConfig, ConnectTarget } from './interfaces/WebClientConfig'; -export type { - KeyOf, - GameEventMeta, - WebSocketSessionResponseOverrides, - WebSocketRoomResponseOverrides, -} from './interfaces/WebSocketConfig'; export { SessionEvents } from './events/session'; export { RoomEvents } from './events/room'; export { GameEvents } from './events/game'; export { generateSalt, passwordSaltSupported, hashPassword } from './utils'; - -export { WebSocketConnectReason } from './interfaces/ConnectOptions'; -export type { - LoginConnectOptions, - RegisterConnectOptions, - ActivateConnectOptions, - PasswordResetRequestConnectOptions, - PasswordResetChallengeConnectOptions, - PasswordResetConnectOptions, - TestConnectionOptions, - WebSocketConnectOptions, -} from './interfaces/ConnectOptions'; - export { setPendingOptions, consumePendingOptions } from './utils/connectionState'; diff --git a/webclient/src/websocket/interfaces/WebSocketConfig.ts b/webclient/src/websocket/interfaces/WebSocketConfig.ts deleted file mode 100644 index cc1dface5..000000000 --- a/webclient/src/websocket/interfaces/WebSocketConfig.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { - GameEventContext, - Response_Login, - Response, - Event_RoomSay, - ResponseMap, - RoomEventMap, -} from '@app/generated'; - -// ── KeyOf utility ──────────────────────────────────────────────────────────── -// Derives a type map key from a generated type. Allows interface methods to -// reference generated types instead of hardcoded string keys. -// -// T[KeyOf] -// ↓ resolves to ↓ -// T['Response_Login'] - -export type KeyOf = { [K in keyof Map]: Map[K] extends V ? K : never }[keyof Map]; - -// ── GameEventMeta ──────────────────────────────────────────────────────────── -// Per-container metadata passed to every game event handler alongside the -// event payload. Constructed by ProtobufService.processGameEvent from the -// GameEventContainer fields. Structurally identical to Enriched.GameEventMeta. - -export interface GameEventMeta { - gameId: number; - playerId: number; - context: GameEventContext | null; - secondsElapsed: number; - forcedByJudge: number; -} - -// ── Websocket-layer enrichments ────────────────────────────────────────────── -// Protocol-level enrichments of proto types — these are websocket concerns, -// not app concerns. Used as the DEFAULT generic on the response interfaces. - -export interface WebSocketSessionResponseOverrides extends ResponseMap { - Response_Login: Response_Login & { hashedPassword?: string }; - Response: Response & { host: string; port: string; userName: string }; -} - -export interface WebSocketRoomResponseOverrides extends RoomEventMap { - Event_RoomSay: Event_RoomSay & { timeReceived: number }; -} diff --git a/webclient/src/websocket/services/KeepAliveService.spec.ts b/webclient/src/websocket/services/KeepAliveService.spec.ts index c14beed1b..81e023658 100644 --- a/webclient/src/websocket/services/KeepAliveService.spec.ts +++ b/webclient/src/websocket/services/KeepAliveService.spec.ts @@ -64,5 +64,14 @@ describe('KeepAliveService', () => { expect(service.endPingLoop).toHaveBeenCalled(); }); + + it('should clear previous interval when startPingLoop is called again', () => { + const clearSpy = vi.spyOn(globalThis, 'clearInterval'); + const previousCb = (service as KeepAliveInternal).keepalivecb; + + service.startPingLoop(interval, ping); + + expect(clearSpy).toHaveBeenCalledWith(previousCb); + }); }); }); diff --git a/webclient/src/websocket/services/KeepAliveService.ts b/webclient/src/websocket/services/KeepAliveService.ts index 4b275cf3c..03f42d2a1 100644 --- a/webclient/src/websocket/services/KeepAliveService.ts +++ b/webclient/src/websocket/services/KeepAliveService.ts @@ -13,6 +13,7 @@ export class KeepAliveService { } public startPingLoop(interval: number, ping: (onPong: () => void) => void): void { + this.endPingLoop(); this.keepalivecb = setInterval(() => { // check if the previous ping got no reply if (this.lastPingPending) { diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts index 7880b9099..44825a057 100644 --- a/webclient/src/websocket/services/ProtobufService.spec.ts +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -112,6 +112,67 @@ describe('ProtobufService', () => { expect((service as ProtobufInternal).cmdId).toBe(0); expect((service as ProtobufInternal).pendingCommands.size).toBe(0); }); + + it('returns true when command is sent', () => { + const service = new ProtobufService(mockSocket); + const result = service.sendCommand(create(CommandContainerSchema), vi.fn()); + expect(result).toBe(true); + }); + + it('returns false when transport is closed', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const result = service.sendCommand(create(CommandContainerSchema), vi.fn()); + expect(result).toBe(false); + }); + }); + + describe('send*Command when transport is closed', () => { + it('calls onError when sendSessionCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendSessionCommand(sessionExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('calls onError when sendRoomCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendRoomCommand(42, roomExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('calls onError when sendGameCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendGameCommand(7, gameExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('calls onError when sendModeratorCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendModeratorCommand(moderatorExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('calls onError when sendAdminCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendAdminCommand(adminExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('does not throw when command is dropped with no options', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + expect(() => service.sendSessionCommand(sessionExt, {})).not.toThrow(); + }); }); describe('sendSessionCommand', () => { @@ -311,9 +372,9 @@ describe('ProtobufService', () => { expect(processGameEvent).toHaveBeenCalled(); }); - it('logs unknown message types (default case)', () => { + it('warns on unknown message types (default case)', () => { const service = new ProtobufService(mockSocket); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.mocked(fromBinary).mockReturnValue( create(ServerMessageSchema, { @@ -449,10 +510,6 @@ describe('ProtobufService', () => { }); -// ── Real protobuf round-trip test ───────────────────────────────────────────── -// This describe block does NOT mock @bufbuild/protobuf so it exercises real -// binary serialization. It proves that the schemas ProtobufService uses -// survive a toBinary → fromBinary cycle without data loss. describe('ProtobufService protobuf round-trip (real @bufbuild/protobuf)', () => { it('CommandContainer round-trips cmdId through toBinary → fromBinary', async () => { const { create, toBinary, fromBinary: realFromBinary } = diff --git a/webclient/src/websocket/services/ProtobufService.ts b/webclient/src/websocket/services/ProtobufService.ts index 05437f80e..23ab52f06 100644 --- a/webclient/src/websocket/services/ProtobufService.ts +++ b/webclient/src/websocket/services/ProtobufService.ts @@ -26,7 +26,7 @@ import { import { GameEvents } from '../events/game'; import { RoomEvents } from '../events/room'; import { SessionEvents } from '../events/session'; -import type { GameEventMeta } from '../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../types/WebSocketConfig'; import { type CommandOptions, handleResponse } from './command-options'; export interface SocketTransport { @@ -54,11 +54,7 @@ export class ProtobufService { const gameCmd = create(GameCommandSchema); setExtension(gameCmd, ext, value); const cmd = create(CommandContainerSchema, { gameId, gameCommand: [gameCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } public sendRoomCommand( @@ -70,11 +66,7 @@ export class ProtobufService { const roomCmd = create(RoomCommandSchema); setExtension(roomCmd, ext, value); const cmd = create(CommandContainerSchema, { roomId, roomCommand: [roomCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } public sendSessionCommand( @@ -85,11 +77,7 @@ export class ProtobufService { const sesCmd = create(SessionCommandSchema); setExtension(sesCmd, ext, value); const cmd = create(CommandContainerSchema, { sessionCommand: [sesCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } public sendModeratorCommand( @@ -100,11 +88,7 @@ export class ProtobufService { const modCmd = create(ModeratorCommandSchema); setExtension(modCmd, ext, value); const cmd = create(CommandContainerSchema, { moderatorCommand: [modCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } public sendAdminCommand( @@ -115,22 +99,31 @@ export class ProtobufService { const adminCmd = create(AdminCommandSchema); setExtension(adminCmd, ext, value); const cmd = create(CommandContainerSchema, { adminCommand: [adminCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } - public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void) { + private dispatchCommand(typeName: string, cmd: CommandContainer, options?: CommandOptions): void { + const sent = this.sendCommand(cmd, raw => { + if (options) { + handleResponse(typeName, raw, options); + } + }); + + if (!sent) { + options?.onError?.(-1, {} as Response); + } + } + + public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void): boolean { if (!this.transport.isOpen()) { - return; + return false; } this.cmdId++; cmd.cmdId = BigInt(this.cmdId); this.pendingCommands.set(this.cmdId, callback); this.transport.send(toBinary(CommandContainerSchema, cmd)); + return true; } public handleMessageEvent({ data }: MessageEvent): void { @@ -153,7 +146,7 @@ export class ProtobufService { this.processGameEvent(msg.gameEventContainer); break; default: - console.log(msg); + console.warn('Unknown message type:', msg); break; } } diff --git a/webclient/src/websocket/services/WebSocketService.spec.ts b/webclient/src/websocket/services/WebSocketService.spec.ts index c565ac74e..83f1d6dc1 100644 --- a/webclient/src/websocket/services/WebSocketService.spec.ts +++ b/webclient/src/websocket/services/WebSocketService.spec.ts @@ -9,7 +9,7 @@ vi.mock('../config', () => ({ import { WebSocketService } from './WebSocketService'; import type { WebSocketServiceConfig } from './WebSocketService'; import { KeepAliveService } from './KeepAliveService'; -import { StatusEnum } from '../interfaces/StatusEnum'; +import { StatusEnum } from '../types/StatusEnum'; type WebSocketInternal = WebSocketService & { keepAliveService: KeepAliveService; @@ -198,6 +198,12 @@ describe('WebSocketService', () => { service.send(data); expect(mockInstance.send).toHaveBeenCalledWith(data); }); + + it('does not throw when socket is undefined (before connect)', () => { + const service = new WebSocketService(mockConfig); + const data = new Uint8Array([1, 2, 3]); + expect(() => service.send(data)).not.toThrow(); + }); }); describe('checkReadyState', () => { diff --git a/webclient/src/websocket/services/WebSocketService.ts b/webclient/src/websocket/services/WebSocketService.ts index cf2321667..b3e082c22 100644 --- a/webclient/src/websocket/services/WebSocketService.ts +++ b/webclient/src/websocket/services/WebSocketService.ts @@ -1,9 +1,9 @@ import { Subject } from 'rxjs'; -import { StatusEnum } from '../interfaces/StatusEnum'; +import { StatusEnum } from '../types/StatusEnum'; import { KeepAliveService } from './KeepAliveService'; import { CLIENT_OPTIONS } from '../config'; -import type { ConnectTarget } from '../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../types/WebClientConfig'; export interface WebSocketServiceConfig { keepAliveFn: (pingReceived: () => void) => void; @@ -16,7 +16,7 @@ export class WebSocketService { private config: WebSocketServiceConfig; private keepAliveService: KeepAliveService; - private errorFired = false; + private hasReportedError = false; public message$: Subject = new Subject(); @@ -54,6 +54,9 @@ export class WebSocketService { } public send(message: Uint8Array): void { + if (!this.socket) { + return; + } this.socket.send(message as unknown as ArrayBufferView); } @@ -65,7 +68,7 @@ export class WebSocketService { socket.onopen = () => { clearTimeout(connectionTimer); - this.errorFired = false; + this.hasReportedError = false; this.config.onStatusChange(StatusEnum.CONNECTED, 'Connected'); this.keepAliveService.startPingLoop(this.keepalive, (pingReceived: () => void) => { @@ -74,16 +77,17 @@ export class WebSocketService { }; socket.onclose = () => { - // dont overwrite failure messages - if (!this.errorFired) { + // @critical onerror + onclose both fire on failed connects; don't overwrite the richer error status. + // See .github/instructions/webclient.instructions.md#websocket-lifecycle. + if (!this.hasReportedError) { this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Closed'); } - this.errorFired = false; + this.hasReportedError = false; this.keepAliveService.endPingLoop(); }; socket.onerror = () => { - this.errorFired = true; + this.hasReportedError = true; this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Failed'); this.config.onConnectionFailed(); }; diff --git a/webclient/src/websocket/services/command-options.spec.ts b/webclient/src/websocket/services/command-options.spec.ts index 19dd644be..a898dfb5e 100644 --- a/webclient/src/websocket/services/command-options.spec.ts +++ b/webclient/src/websocket/services/command-options.spec.ts @@ -10,12 +10,6 @@ import { create, getExtension } from '@bufbuild/protobuf'; import { handleResponse } from './command-options'; -// NOTE: do NOT call `vi.resetAllMocks()` here — under `isolate: false` it -// resets `vi.fn()` implementations set inside other files' `vi.mock(...)` -// factories, which breaks any spec that relied on those factory defaults -// (e.g. ProtobufService.spec.ts expects `hasExtension` to return `false`). -// The root `setupTests.ts` afterEach already calls `vi.clearAllMocks()`. - describe('handleResponse', () => { it('calls onResponse and returns early when provided', () => { const onResponse = vi.fn(); diff --git a/webclient/src/websocket/interfaces/ConnectOptions.ts b/webclient/src/websocket/types/ConnectOptions.ts similarity index 80% rename from webclient/src/websocket/interfaces/ConnectOptions.ts rename to webclient/src/websocket/types/ConnectOptions.ts index a74402fb4..5929363b4 100644 --- a/webclient/src/websocket/interfaces/ConnectOptions.ts +++ b/webclient/src/websocket/types/ConnectOptions.ts @@ -10,12 +10,6 @@ export enum WebSocketConnectReason { TEST_CONNECTION, } -// ── Connect options ─────────────────────────────────────────────────────────── -// Each variant is the enriched input for one session flow: the network -// transport fields (host/port) + the subset of proto Command_* fields the UI -// actually produces (user-entered credentials, tokens, email, etc.) + a -// `reason` discriminator so the websocket layer can route. - interface ConnectTransport extends ConnectTarget { keepalive?: number; autojoinrooms?: boolean; diff --git a/webclient/src/websocket/types/SignalContexts.ts b/webclient/src/websocket/types/SignalContexts.ts new file mode 100644 index 000000000..e66b659a2 --- /dev/null +++ b/webclient/src/websocket/types/SignalContexts.ts @@ -0,0 +1,17 @@ +/** + * Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the + * activation dialog can resubmit against the same host/user without re-entering them. + */ +export interface PendingActivationContext { + host: string; + port: string; + userName: string; +} + +/** + * Payload for the LOGIN_SUCCESSFUL signal. Only carries what the UI needs to + * persist into the selected host record (hashedPassword for "remember me"). + */ +export interface LoginSuccessContext { + hashedPassword?: string; +} diff --git a/webclient/src/websocket/interfaces/StatusEnum.ts b/webclient/src/websocket/types/StatusEnum.ts similarity index 100% rename from webclient/src/websocket/interfaces/StatusEnum.ts rename to webclient/src/websocket/types/StatusEnum.ts diff --git a/webclient/src/websocket/interfaces/WebClientConfig.ts b/webclient/src/websocket/types/WebClientConfig.ts similarity index 100% rename from webclient/src/websocket/interfaces/WebClientConfig.ts rename to webclient/src/websocket/types/WebClientConfig.ts diff --git a/webclient/src/websocket/interfaces/WebClientRequest.ts b/webclient/src/websocket/types/WebClientRequest.ts similarity index 96% rename from webclient/src/websocket/interfaces/WebClientRequest.ts rename to webclient/src/websocket/types/WebClientRequest.ts index 16db5fd08..5caf83fda 100644 --- a/webclient/src/websocket/interfaces/WebClientRequest.ts +++ b/webclient/src/websocket/types/WebClientRequest.ts @@ -39,7 +39,6 @@ import type { import type { ConnectTarget } from './WebClientConfig'; import type { KeyOf } from './WebSocketConfig'; -// ── Auth request type map ──────────────────────────────────────────────────── // Keys = generated *Params type names composed with ConnectTarget. // @app/api overrides these with Enriched connect option types. diff --git a/webclient/src/websocket/interfaces/WebClientResponse.ts b/webclient/src/websocket/types/WebClientResponse.ts similarity index 98% rename from webclient/src/websocket/interfaces/WebClientResponse.ts rename to webclient/src/websocket/types/WebClientResponse.ts index cf3bd40d5..4000021ee 100644 --- a/webclient/src/websocket/interfaces/WebClientResponse.ts +++ b/webclient/src/websocket/types/WebClientResponse.ts @@ -134,7 +134,7 @@ export interface IGameResponse { gameClosed(gameId: number): void; gameHostChanged(gameId: number, hostId: number): void; kicked(gameId: number): void; - gameSay(gameId: number, playerId: number, message: string): void; + gameSay(gameId: number, playerId: number, message: string, timeReceived: number): void; cardMoved(gameId: number, playerId: number, data: Event_MoveCard): void; cardFlipped(gameId: number, playerId: number, data: Event_FlipCard): void; cardDestroyed(gameId: number, playerId: number, data: Event_DestroyCard): void; diff --git a/webclient/src/websocket/types/WebSocketConfig.ts b/webclient/src/websocket/types/WebSocketConfig.ts new file mode 100644 index 000000000..8037dcf2d --- /dev/null +++ b/webclient/src/websocket/types/WebSocketConfig.ts @@ -0,0 +1,28 @@ +import type { + GameEventContext, + Response_Login, + Response, + Event_RoomSay, + ResponseMap, + RoomEventMap, +} from '@app/generated'; + +// `KeyOf` resolves to `'Response_Login'`. +export type KeyOf = { [K in keyof Map]: Map[K] extends V ? K : never }[keyof Map]; + +export interface GameEventMeta { + gameId: number; + playerId: number; + context: GameEventContext | null; + secondsElapsed: number; + forcedByJudge: number; +} + +export interface WebSocketSessionResponseOverrides extends ResponseMap { + Response_Login: Response_Login & { hashedPassword?: string }; + Response: Response & { host: string; port: string; userName: string }; +} + +export interface WebSocketRoomResponseOverrides extends RoomEventMap { + Event_RoomSay: Event_RoomSay & { timeReceived: number }; +} diff --git a/webclient/src/websocket/types/index.ts b/webclient/src/websocket/types/index.ts new file mode 100644 index 000000000..3e3c127cc --- /dev/null +++ b/webclient/src/websocket/types/index.ts @@ -0,0 +1 @@ +export * as WebsocketTypes from './namespace'; diff --git a/webclient/src/websocket/interfaces/index.ts b/webclient/src/websocket/types/namespace.ts similarity index 87% rename from webclient/src/websocket/interfaces/index.ts rename to webclient/src/websocket/types/namespace.ts index 6c4459c91..84ae11770 100644 --- a/webclient/src/websocket/interfaces/index.ts +++ b/webclient/src/websocket/types/namespace.ts @@ -21,3 +21,5 @@ export type { export * from './WebClientConfig'; export * from './WebSocketConfig'; export * from './StatusEnum'; +export * from './ConnectOptions'; +export * from './SignalContexts'; diff --git a/webclient/src/websocket/utils/connectionState.ts b/webclient/src/websocket/utils/connectionState.ts index de4866462..02d0d7002 100644 --- a/webclient/src/websocket/utils/connectionState.ts +++ b/webclient/src/websocket/utils/connectionState.ts @@ -1,4 +1,4 @@ -import type { WebSocketConnectOptions } from '../interfaces/ConnectOptions'; +import type { WebSocketConnectOptions } from '../types/ConnectOptions'; let pendingOptions: WebSocketConnectOptions | null = null; diff --git a/webclient/src/websocket/utils/passwordHasher.ts b/webclient/src/websocket/utils/passwordHasher.ts index 6a230d9ea..e89302244 100644 --- a/webclient/src/websocket/utils/passwordHasher.ts +++ b/webclient/src/websocket/utils/passwordHasher.ts @@ -8,7 +8,6 @@ const SALT_LENGTH = 16; export const hashPassword = (salt: string, password: string): string => { let hashedPassword = salt + password; for (let i = 0; i < HASH_ROUNDS; i++) { - // WHY DO WE DO IT THIS WAY? hashedPassword = sha512(hashedPassword); } @@ -27,6 +26,6 @@ export const generateSalt = (): string => { } export const passwordSaltSupported = (serverOptions: number): number => { - // Intentional use of Bitwise operator b/c of how Servatrice Enums work + // @critical Servatrice ServerOptions is a bitmask. See .github/instructions/webclient.instructions.md#protocol-quirks. return serverOptions & Event_ServerIdentification_ServerOptions.SupportsPasswordHash; } diff --git a/webclient/tsconfig.json b/webclient/tsconfig.json index 1e851244e..b070278d5 100644 --- a/webclient/tsconfig.json +++ b/webclient/tsconfig.json @@ -35,6 +35,7 @@ "@app/store": ["./src/store/index.ts"], "@app/types": ["./src/types/index.ts"], "@app/websocket": ["./src/websocket/index.ts"], + "@app/websocket/types": ["./src/websocket/types/index.ts"], "@app/generated": ["./src/generated/index.ts"] } }, diff --git a/webclient/vite.config.ts b/webclient/vite.config.ts index 1c1157f0a..225e64adc 100644 --- a/webclient/vite.config.ts +++ b/webclient/vite.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ }, server: { open: true, + watch: { + ignored: ['build', 'coverage', 'integration'] + } }, test: { globals: true, diff --git a/webclient/vitest.integration.config.ts b/webclient/vitest.integration.config.ts index 0b88f927a..2ceabd4d8 100644 --- a/webclient/vitest.integration.config.ts +++ b/webclient/vitest.integration.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from 'vitest/config'; // Integration tests exercise the full inbound/outbound webclient pipeline // (ProtobufService → event handlers → persistence → Redux) with only the -// browser WebSocket constructor mocked. They live in `integration/` and run -// under their own config so they can use `isolate: true` without slowing down -// the unit suite (which relies on `isolate: false` for shared vi.mock state). +// browser WebSocket constructor mocked. They live in `integration/` with +// their own config so the include glob and longer testTimeout stay scoped +// to this suite; both suites run `isolate: true`. export default defineConfig({ plugins: [react()], resolve: { @@ -15,7 +15,11 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./integration/src/helpers/setup.ts'], - include: ['integration/src/**/*.spec.ts'], + include: ['integration/src/**/*.spec.{ts,tsx}'], + // App-suite tests render the full Login container against real Dexie + // (fake-indexeddb) + real WebClient. Under CI/disk load the default + // 5s timeout is tight; 10s leaves headroom without masking real hangs. + testTimeout: 10000, coverage: { provider: 'v8', reporter: ['text', 'html'],