mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
cleanup testing utilities, documentation, and AI commentary
This commit is contained in:
parent
bd2382c94e
commit
ef6cea6f6c
150 changed files with 891 additions and 1233 deletions
168
.github/instructions/webclient.instructions.md
vendored
Normal file
168
.github/instructions/webclient.instructions.md
vendored
Normal file
|
|
@ -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#<anchor>`, 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/<scope>/`, inbound handlers in `events/<scope>/`, 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.<scope>.<method>(…)`. 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<number, callback>` 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<R>`:
|
||||
|
||||
- `responseExt?: GenExtension<Response, R>` — 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/<slice>/*` — 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.<same-name>` 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.
|
||||
|
|
@ -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<number, callback>` 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<R>`:
|
||||
|
||||
- `responseExt?: GenExtension<Response, R>` — 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/`.
|
||||
|
|
@ -1,73 +1,67 @@
|
|||
# Webatrice
|
||||
|
||||
The Cockatrice web client — a React/TypeScript SPA that connects to a Servatrice server over a WebSocket.
|
||||
|
||||
## Application Architecture
|
||||

|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||

|
||||
|
||||
## 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.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
## Prerequisites
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
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.<br />
|
||||
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.<br />
|
||||
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.<br />
|
||||
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<RouteComponentProps<???, ???, ???>>
|
||||
|
||||
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/)
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
70
webclient/architecture/README.md
Normal file
70
webclient/architecture/README.md
Normal file
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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/<name>.mmd -o architecture/<name>.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.
|
||||
123
webclient/architecture/detailed.mmd
Normal file
123
webclient/architecture/detailed.mmd
Normal file
|
|
@ -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["<b>Application</b>"]
|
||||
direction TB
|
||||
UI["<b>UI</b><br/><span style='font-size:15px'>containers · components<br/>forms · dialogs</span>"]
|
||||
Hooks["<b>hooks/</b><br/><span style='font-size:15px'>useWebClient · useAutoLogin<br/>useSettings · useKnownHosts</span>"]
|
||||
Store[("<b>@app/store</b><br/><span style='font-size:15px'>server · rooms · game<br/>actions · common</span>")]
|
||||
DTOs["<b>dexie DTOs</b><br/><span style='font-size:15px'>Card · Host · Set<br/>Setting · Token</span>"]
|
||||
IDB[("<b>IndexedDB</b>")]
|
||||
end
|
||||
|
||||
%% =========================================================
|
||||
%% Racetrack — three lanes: outbound / transport / inbound
|
||||
%% =========================================================
|
||||
subgraph RACE[" "]
|
||||
direction TB
|
||||
|
||||
subgraph TOP["<b>Outbound lane</b>"]
|
||||
direction LR
|
||||
Req["<b>src/api/request/</b><br/><span style='font-size:15px'>Authentication · Session · Rooms<br/>Game · Admin · Moderator</span>"]
|
||||
Cmds["<b>commands/</b><br/><span style='font-size:15px'>session · room · game<br/>admin · moderator</span>"]
|
||||
end
|
||||
|
||||
subgraph MID["<b>Transport</b>"]
|
||||
direction LR
|
||||
Provider["<b>WebClientProvider</b>"]
|
||||
WC[["<b>WebClient</b><br/><span style='font-size:15px'>singleton · request · response</span>"]]
|
||||
Svc["<b>services/</b><br/><span style='font-size:15px'>ProtobufService · WebSocketService<br/>KeepAliveService · command-options</span>"]
|
||||
end
|
||||
|
||||
subgraph BOT["<b>Inbound lane</b>"]
|
||||
direction LR
|
||||
Evts["<b>events/</b><br/><span style='font-size:15px'>session · room · game</span>"]
|
||||
Res["<b>src/api/response/</b><br/><span style='font-size:15px'>Session · Room · Game<br/>Admin · Moderator</span>"]
|
||||
end
|
||||
end
|
||||
|
||||
%% =========================================================
|
||||
%% Right bookend — Servatrice
|
||||
%% =========================================================
|
||||
Srv[("<b>Servatrice</b>")]
|
||||
|
||||
%% =========================================================
|
||||
%% Protocol satellite — cross-cutting types
|
||||
%% =========================================================
|
||||
subgraph PROTO["<b>Protocol (cross-cutting)</b>"]
|
||||
direction LR
|
||||
Types["<b>src/types/</b><br/><span style='font-size:15px'>Data · Enriched · App</span>"]
|
||||
Gen["<b>src/generated/proto/</b><br/><span style='font-size:15px'>@bufbuild/protobuf</span>"]
|
||||
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
|
||||
BIN
webclient/architecture/detailed.png
Normal file
BIN
webclient/architecture/detailed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
41
webclient/architecture/flow.mmd
Normal file
41
webclient/architecture/flow.mmd
Normal file
|
|
@ -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,<br/>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.<br/>resetCommands() on reconnect<br/>silently drops pending callbacks.
|
||||
BIN
webclient/architecture/flow.png
Normal file
BIN
webclient/architecture/flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
58
webclient/architecture/simple.mmd
Normal file
58
webclient/architecture/simple.mmd
Normal file
|
|
@ -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["<b>Application</b><br/><span style='font-size:13px'>containers · components · hooks</span>"]
|
||||
Rdx[("<b>Redux</b><br/><span style='font-size:12px'>in-memory state</span>")]
|
||||
end
|
||||
|
||||
subgraph SRV_COL[" "]
|
||||
direction TB
|
||||
Srv[("<b>Servatrice</b>")]
|
||||
IDB[("<b>IndexedDB</b><br/><span style='font-size:12px'>local persistent store</span>")]
|
||||
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
|
||||
BIN
webclient/architecture/simple.png
Normal file
BIN
webclient/architecture/simple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
|
|
@ -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: 'websocket' }, allow: types('generated') },
|
||||
{ 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: '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: 'images' }, allow: types('types') },
|
||||
{ from: { type: 'services' }, allow: types('api', 'store', 'types') },
|
||||
{ from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket') },
|
||||
{ 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 = [
|
||||
|
|
|
|||
|
|
@ -25,14 +25,14 @@ const flushStoresAndEffects = async (): Promise<void> => {
|
|||
});
|
||||
};
|
||||
|
||||
import { autoLoginSession } from '../../../src/hooks/useAutoLogin';
|
||||
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 { StatusEnum } from '@app/websocket';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
|
||||
import { resetDexie } from '../services/dexie/resetDexie';
|
||||
import { renderAppScreen, store } from './helpers';
|
||||
|
|
@ -41,7 +41,7 @@ import { renderAppScreen, store } from './helpers';
|
|||
// dispatching updateStatus(DISCONNECTED) is what the real reducer uses to
|
||||
// clear connectionAttemptMade (clearStore intentionally preserves status).
|
||||
const simulateLogout = () => {
|
||||
ServerDispatch.updateStatus(StatusEnum.DISCONNECTED, null);
|
||||
ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null);
|
||||
};
|
||||
|
||||
const seedAutoConnect = async () => {
|
||||
|
|
@ -86,7 +86,7 @@ beforeEach(async () => {
|
|||
// cached values).
|
||||
settingsStore.reset();
|
||||
knownHostsStore.reset();
|
||||
autoLoginSession.startupCheckRan = false;
|
||||
autoLoginGate.hasChecked = false;
|
||||
});
|
||||
|
||||
describe('autoconnect — cold start', () => {
|
||||
|
|
@ -182,7 +182,7 @@ describe('autoconnect — refresh', () => {
|
|||
// Simulate a browser refresh: the session gate naturally resets on a
|
||||
// fresh JS context, and the real connection flag resets too.
|
||||
simulateLogout();
|
||||
autoLoginSession.startupCheckRan = false;
|
||||
autoLoginGate.hasChecked = false;
|
||||
|
||||
renderAppScreen(<Login />);
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -19,13 +19,8 @@ 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';
|
||||
|
||||
|
|
@ -109,7 +104,7 @@ function resetAll(): void {
|
|||
}
|
||||
|
||||
client.protobuf.resetCommands();
|
||||
client.status = StatusEnum.DISCONNECTED;
|
||||
client.status = WebsocketTypes.StatusEnum.DISCONNECTED;
|
||||
|
||||
ServerDispatch.clearStore();
|
||||
RoomsDispatch.clearStore();
|
||||
|
|
@ -128,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',
|
||||
|
|
@ -137,16 +132,16 @@ const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = {
|
|||
};
|
||||
|
||||
export function connectRaw(
|
||||
overrides: Partial<WebSocketConnectOptions> = {}
|
||||
overrides: Partial<WebsocketTypes.WebSocketConnectOptions> = {}
|
||||
): 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<WebSocketConnectOptions> = {}
|
||||
overrides: Partial<WebsocketTypes.WebSocketConnectOptions> = {}
|
||||
): void {
|
||||
connectRaw(overrides);
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
|
|
@ -160,7 +155,7 @@ export function connectAndHandshake(
|
|||
}
|
||||
|
||||
export function connectAndHandshakeWithSalt(
|
||||
overrides: Partial<WebSocketConnectOptions> = {}
|
||||
overrides: Partial<WebsocketTypes.WebSocketConnectOptions> = {}
|
||||
): void {
|
||||
connectRaw(overrides);
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ 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 {
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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';
|
||||
|
||||
|
|
@ -18,17 +18,15 @@ import {
|
|||
setPendingOptions,
|
||||
connectAndHandshake,
|
||||
} from '../helpers/setup';
|
||||
import type { WebSocketConnectOptions } from '@app/websocket';
|
||||
import { WebSocketConnectReason } from '@app/websocket';
|
||||
import {
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@ 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 {
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ 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 {
|
||||
|
|
@ -19,7 +19,7 @@ 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@ 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 {
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<Location>): () => void {
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
|
||||
|
|
@ -26,23 +20,3 @@ export function withMockLocation(overrides: Partial<Location>): () => 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<T>(registry: T[], entry: T): () => void {
|
||||
registry.push(entry);
|
||||
return () => {
|
||||
const index = registry.lastIndexOf(entry);
|
||||
if (index !== -1) {
|
||||
registry.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +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';
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ 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 this into
|
||||
* tests via `renderWithProviders({ webClient: createMockWebClient() })`.
|
||||
* 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 {
|
||||
|
|
|
|||
|
|
@ -14,12 +14,8 @@ import { actionReducer } from '../store/actions';
|
|||
import { ToastProvider } from '../components/Toast/ToastContext';
|
||||
import type { RootState } from '../store/store';
|
||||
|
||||
// Minimal i18n instance for tests — returns keys as-is. A non-empty
|
||||
// `resources` entry is required so i18next registers `en-US` as a known
|
||||
// language; otherwise `i18n.resolvedLanguage` stays `undefined`, which
|
||||
// LanguageDropdown seeds into a MUI Select and MUI warns "out-of-range
|
||||
// value `undefined`". Value is an empty translation map, since tests
|
||||
// already assert on i18n keys directly.
|
||||
// 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',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { App, Data, Enriched } from '@app/types';
|
||||
import { App, Data } from '@app/types';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
import type { RootState } from '../store/store';
|
||||
|
||||
/**
|
||||
|
|
@ -30,7 +31,7 @@ export const disconnectedState: Partial<RootState> = {
|
|||
ignoreList: {},
|
||||
status: {
|
||||
connectionAttemptMade: false,
|
||||
state: Enriched.StatusEnum.DISCONNECTED,
|
||||
state: WebsocketTypes.StatusEnum.DISCONNECTED,
|
||||
description: null,
|
||||
},
|
||||
info: { message: null, name: null, version: null },
|
||||
|
|
@ -77,7 +78,7 @@ export const connectedState: Partial<RootState> = {
|
|||
initialized: true,
|
||||
status: {
|
||||
connectionAttemptMade: true,
|
||||
state: Enriched.StatusEnum.LOGGED_IN,
|
||||
state: WebsocketTypes.StatusEnum.LOGGED_IN,
|
||||
description: null,
|
||||
},
|
||||
info: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LoginConnectOptions, 'reason'>;
|
||||
ConnectTarget: Omit<TestConnectionOptions, 'reason'>;
|
||||
RegisterParams: Omit<RegisterConnectOptions, 'reason'>;
|
||||
ActivateParams: Omit<ActivateConnectOptions, 'reason'>;
|
||||
ForgotPasswordRequestParams: Omit<PasswordResetRequestConnectOptions, 'reason'>;
|
||||
ForgotPasswordChallengeParams: Omit<PasswordResetChallengeConnectOptions, 'reason'>;
|
||||
ForgotPasswordResetParams: Omit<PasswordResetConnectOptions, 'reason'>;
|
||||
interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap {
|
||||
LoginParams: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>;
|
||||
ConnectTarget: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>;
|
||||
RegisterParams: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>;
|
||||
ActivateParams: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>;
|
||||
ForgotPasswordRequestParams: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>;
|
||||
ForgotPasswordChallengeParams: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>;
|
||||
ForgotPasswordResetParams: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>;
|
||||
}
|
||||
|
||||
export class AuthenticationRequestImpl implements IAuthenticationRequest<AppAuthRequestOverrides> {
|
||||
login(options: Omit<LoginConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.LOGIN });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest<AppAuthRequestOverrides> {
|
||||
login(options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>): 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<TestConnectionOptions, 'reason'>): void {
|
||||
testConnection(options: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>): void {
|
||||
WebClient.instance.testConnect({ host: options.host, port: options.port });
|
||||
}
|
||||
|
||||
register(options: Omit<RegisterConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.REGISTER });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
register(options: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>): 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<ActivateConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
activateAccount(options: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>): 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<PasswordResetRequestConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
resetPasswordRequest(options: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>): 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<PasswordResetChallengeConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
resetPasswordChallenge(options: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>): 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<PasswordResetConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
resetPassword(options: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET });
|
||||
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
|
||||
WebClient.instance.connect({ host: options.host, port: options.port });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WebSocketRoomResponseOverrides> {
|
||||
export class RoomResponseImpl implements WebsocketTypes.IRoomResponse<WebsocketTypes.WebSocketRoomResponseOverrides> {
|
||||
clearStore(): void {
|
||||
RoomsDispatch.clearStore();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WebSocketSessionResponseOverrides> {
|
||||
export class SessionResponseImpl implements WebsocketTypes.ISessionResponse<WebsocketTypes.WebSocketSessionResponseOverrides> {
|
||||
initialized(): void {
|
||||
ServerDispatch.initialized();
|
||||
}
|
||||
|
|
@ -67,8 +66,8 @@ export class SessionResponseImpl implements ISessionResponse<WebSocketSessionRes
|
|||
ServerDispatch.updateInfo(name, version);
|
||||
}
|
||||
|
||||
updateStatus(state: StatusEnum, description: string): void {
|
||||
if (state === StatusEnum.DISCONNECTED) {
|
||||
updateStatus(state: WebsocketTypes.StatusEnum, description: string): void {
|
||||
if (state === WebsocketTypes.StatusEnum.DISCONNECTED) {
|
||||
GameDispatch.clearStore();
|
||||
RoomsDispatch.clearStore();
|
||||
ServerDispatch.clearStore();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IWebClientResponse } from '@app/websocket';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
|
||||
import { SessionResponseImpl } from './SessionResponseImpl';
|
||||
import { RoomResponseImpl } from './RoomResponseImpl';
|
||||
|
|
@ -8,7 +8,7 @@ import { ModeratorResponseImpl } from './ModeratorResponseImpl';
|
|||
|
||||
export { SessionResponseImpl, RoomResponseImpl, GameResponseImpl, AdminResponseImpl, ModeratorResponseImpl };
|
||||
|
||||
export function createWebClientResponse(): IWebClientResponse {
|
||||
export function createWebClientResponse(): WebsocketTypes.IWebClientResponse {
|
||||
return {
|
||||
session: new SessionResponseImpl(),
|
||||
room: new RoomResponseImpl(),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ interface CardProps {
|
|||
card: CardDTO;
|
||||
}
|
||||
|
||||
// @TODO: add missing fields (loyalty, hand, etc)
|
||||
// @TODO add missing fields (loyalty, hand, etc)
|
||||
|
||||
const CardDetails = ({ card }: CardProps) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -11,11 +11,8 @@ import './LanguageDropdown.css';
|
|||
|
||||
const LanguageDropdown = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
// `resolvedLanguage` can be undefined when i18next hasn't matched the
|
||||
// active lng against any registered resource yet — most often at the
|
||||
// first render in tests with a minimal i18n instance. Fall back to
|
||||
// `i18n.language` (always set to whatever was passed to init) and then
|
||||
// to empty string so MUI's Select has a concrete, in-range value.
|
||||
// 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(() => {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { ToastProvider } from '@app/components'
|
|||
|
||||
function AppShell() {
|
||||
useEffect(() => {
|
||||
// @TODO (1)
|
||||
window.onbeforeunload = () => true;
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,7 @@
|
|||
/**
|
||||
* Login auto-connect integration tests.
|
||||
*
|
||||
* Exercises the full wire from `useAutoLogin` through the Login container
|
||||
* into `webClient.request.authentication.login`. Scenarios mirror the user-
|
||||
* visible cycles we care about:
|
||||
* - cold start with / without auto-connect
|
||||
* - logout within the same session must NOT re-auto-connect
|
||||
* - page refresh (fresh JS context) resets the gate
|
||||
*
|
||||
* The startup-check gate lives on the `autoLoginSession` object exported by
|
||||
* `useAutoLogin.ts`. Tests flip it back to false in `beforeEach` to stand in
|
||||
* for a page refresh between scenarios. `vi.resetModules()` would be the
|
||||
* natural equivalent but is prohibitively slow in the full suite because it
|
||||
* forces every imported module to re-evaluate.
|
||||
*/
|
||||
|
||||
import { act, waitFor } from '@testing-library/react';
|
||||
|
||||
import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__';
|
||||
|
||||
// Lets pending microtasks resolve inside an act() scope so that the state
|
||||
// updates they trigger (useFormState subscribers, useFireOnce state, etc.)
|
||||
// are captured. Without this, useAutoLogin's Promise.all resolves *after*
|
||||
// render returns, and React warns "update ... was not wrapped in act".
|
||||
const flushEffects = async (): Promise<void> => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
|
@ -30,7 +9,7 @@ const flushEffects = async (): Promise<void> => {
|
|||
};
|
||||
import { makeSettings, makeSettingsHook } from '../../hooks/__mocks__/useSettings';
|
||||
import { makeHost, makeKnownHostsHook } from '../../hooks/__mocks__/useKnownHosts';
|
||||
import { autoLoginSession } from '../../hooks/useAutoLogin';
|
||||
import { autoLoginGate } from '../../hooks/useAutoLogin';
|
||||
import { LoadingState } from '@app/hooks';
|
||||
import Login from './Login';
|
||||
|
||||
|
|
@ -62,22 +41,12 @@ beforeAll(() => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Absorb any state updates that lingered past the test body (e.g.
|
||||
// useAutoLogin's Promise.all resolving a moment too late) so they're
|
||||
// wrapped in act and don't trip React's warning during teardown.
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// "Page refresh" between tests: reset the session gate that useAutoLogin
|
||||
// uses to prevent re-firing within a JS session. Production code only
|
||||
// writes this flag once, from inside the startup effect; tests flip it
|
||||
// back to false here to simulate a fresh browser tab.
|
||||
autoLoginSession.startupCheckRan = false;
|
||||
autoLoginGate.hasChecked = false;
|
||||
|
||||
// clearAllMocks in the global afterEach only clears call history; mock
|
||||
// implementations (mockResolvedValue, mockReturnValue) persist. Reset
|
||||
// them explicitly so a previous test's arming doesn't leak into this one.
|
||||
hoisted.getSettings.mockReset();
|
||||
hoisted.getKnownHosts.mockReset();
|
||||
hoisted.useSettings.mockReset();
|
||||
|
|
@ -167,39 +136,26 @@ describe('Login — logout cycle (same JS session)', () => {
|
|||
test('does not re-auto-connect after first auto-login + logout', async () => {
|
||||
armAutoConnect();
|
||||
|
||||
// First mount: Login appears, useAutoLogin fires login.
|
||||
const first = renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
await waitFor(() => {
|
||||
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Simulate arriving at /server and then logging out: Login unmounts,
|
||||
// then a fresh Login mounts again with disconnected state.
|
||||
first.unmount();
|
||||
renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
|
||||
await flushEffects();
|
||||
|
||||
// No second login call — the session gate is latched.
|
||||
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not auto-connect when user enabled autoConnect mid-session and then logged out', async () => {
|
||||
// Scenario: user manually logs in with autoConnect=false. They tick the
|
||||
// auto-connect checkbox during that session (the setting flips true).
|
||||
// They log out. Returning to /login must NOT auto-connect — the setting
|
||||
// change was a preference for NEXT launch, not a signal to log in.
|
||||
|
||||
// First mount: autoConnect=false, so the startup check runs and finds
|
||||
// nothing to do. The gate latches anyway.
|
||||
const first = renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
await flushEffects();
|
||||
expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled();
|
||||
|
||||
first.unmount();
|
||||
|
||||
// Mid-session, user ticked the checkbox. Future getSettings resolves
|
||||
// return the new value, but the session gate prevents a re-check.
|
||||
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true }));
|
||||
|
||||
renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
|
|
@ -209,10 +165,6 @@ describe('Login — logout cycle (same JS session)', () => {
|
|||
});
|
||||
|
||||
describe('Login — refresh cycle', () => {
|
||||
// `beforeEach` flips autoLoginSession.startupCheckRan back to false, which
|
||||
// stands in for a page refresh. This test just re-asserts the positive
|
||||
// case: a refresh re-enables auto-connect when the persisted preference
|
||||
// still says yes.
|
||||
test('a fresh session gate re-fires auto-login when conditions still hold', async () => {
|
||||
armAutoConnect();
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import { LoginForm } from '@app/forms';
|
|||
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||
import { Images } from '@app/images';
|
||||
import { getHostPort, serverProps } from '@app/services';
|
||||
import { App, Enriched } from '@app/types';
|
||||
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';
|
||||
|
|
@ -70,7 +71,7 @@ const Login = () => {
|
|||
const webClient = useWebClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [pendingActivationOptions, setPendingActivationOptions] = useState<Enriched.PendingActivationContext | null>(null);
|
||||
const [pendingActivationOptions, setPendingActivationOptions] = useState<WebsocketTypes.PendingActivationContext | null>(null);
|
||||
|
||||
const rememberLoginRef = useRef<any>(null);
|
||||
const knownHosts = useKnownHosts();
|
||||
|
|
@ -128,7 +129,7 @@ const Login = () => {
|
|||
rememberLoginRef.current = loginForm;
|
||||
const { userName, password, selectedHost, remember } = loginForm;
|
||||
|
||||
const options: Omit<Enriched.LoginConnectOptions, 'reason'> = {
|
||||
const options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'> = {
|
||||
...getHostPort(selectedHost),
|
||||
userName,
|
||||
password,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -58,9 +58,6 @@ describe('LoginForm — regression: settings.autoConnect is not clobbered by hos
|
|||
{ preloadedState: disconnectedState }
|
||||
);
|
||||
|
||||
// After mount + all host-sync effects settle, the form has updated its
|
||||
// local fields to reflect the selected host. What MUST NOT happen is a
|
||||
// write to the persisted autoConnect setting.
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -47,9 +47,7 @@ const LoginFormBody = ({
|
|||
|
||||
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
|
||||
|
||||
// Host-sync: when the selected host changes, mirror its username + stored-
|
||||
// password hint into the form. Deliberately does NOT touch autoConnect — the
|
||||
// persisted setting is decoupled from which host is currently picked.
|
||||
// @critical Host-sync must not touch autoConnect — app-level setting, not per-host.
|
||||
useEffect(() => {
|
||||
if (!selectedHost) {
|
||||
return;
|
||||
|
|
@ -65,8 +63,6 @@ const LoginFormBody = ({
|
|||
);
|
||||
}, [selectedHost, form]);
|
||||
|
||||
// Mirror the persisted autoConnect setting into the form checkbox so the
|
||||
// field reflects truth as soon as settings load.
|
||||
useEffect(() => {
|
||||
if (settings.status !== LoadingState.READY) {
|
||||
return;
|
||||
|
|
@ -82,11 +78,7 @@ const LoginFormBody = ({
|
|||
};
|
||||
|
||||
const onRememberChange = (checked: boolean) => {
|
||||
// When the user unchecks "remember password", the auto-connect checkbox
|
||||
// can't meaningfully stay on (there are no saved credentials to use), so
|
||||
// reflect that in the form UI. Note: this writes only to the form field,
|
||||
// NOT to the persisted setting — toggling host-level remember is not a
|
||||
// user intent to change the app-level auto-connect preference.
|
||||
// @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit.
|
||||
if (!checked && values.autoConnect) {
|
||||
form.change('autoConnect', false);
|
||||
}
|
||||
|
|
@ -94,11 +86,8 @@ const LoginFormBody = ({
|
|||
togglePasswordLabel(canUseStoredPassword(checked, values.password));
|
||||
};
|
||||
|
||||
// User-initiated toggle of the auto-connect checkbox. This is the ONLY path
|
||||
// that writes to the persisted setting — wired directly to the Checkbox's
|
||||
// native onChange (see JSX below), not to a <OnChange> listener, because
|
||||
// OnChange fires on programmatic form.change calls too (host-sync effects
|
||||
// etc.) and would leak those into Dexie.
|
||||
// @critical Only persist-path for autoConnect; wired to native onChange, not <OnChange>,
|
||||
// to avoid leaking form.change() writes into Dexie.
|
||||
const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => {
|
||||
fieldOnChange(checked);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ let makeSettings: (o?: AnyRecord) => AnyRecord;
|
|||
let makeHost: (o?: AnyRecord) => AnyRecord;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh module graph per test so the module-level hasFiredThisSession flag resets.
|
||||
// Fresh module graph per test so autoLoginGate.hasChecked resets.
|
||||
vi.resetModules();
|
||||
useAutoLoginModule = await import('./useAutoLogin');
|
||||
const settingsMockModule = await import('./__mocks__/useSettings');
|
||||
|
|
@ -119,21 +119,14 @@ describe('useAutoLogin', () => {
|
|||
});
|
||||
|
||||
test('manual login then logout does NOT auto-connect on return to /login', async () => {
|
||||
// Regression: the flag tracks whether the startup check RAN, not whether
|
||||
// it FIRED. Without that distinction, a first-session manual login (where
|
||||
// the hook saw conditions unmet) would leave the flag unset, and the
|
||||
// next mount (after logout) would find conditions met and auto-connect.
|
||||
const onLogin = vi.fn();
|
||||
|
||||
// First mount: autoConnect=false, so the check runs but doesn't fire.
|
||||
configure({ autoConnect: false, remember: true, hashedPassword: 'hp' });
|
||||
const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
|
||||
// User logs in manually and later hits logout; Login re-mounts with
|
||||
// autoConnect now flipped on (they ticked the box during the session).
|
||||
unmount();
|
||||
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
|
||||
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
|
|
@ -144,10 +137,6 @@ describe('useAutoLogin', () => {
|
|||
});
|
||||
|
||||
test('ticking the auto-connect checkbox after mount does NOT trigger a login', async () => {
|
||||
// This is the specific regression: editing the persisted preference is a
|
||||
// settings write, not a "log in now" signal. Because useAutoLogin reads
|
||||
// via whenReady (one-shot) instead of subscribing, a subsequent settings
|
||||
// change cannot re-run the orchestrator.
|
||||
const onLogin = vi.fn();
|
||||
configure({ autoConnect: false, remember: true, hashedPassword: 'hp' });
|
||||
|
||||
|
|
@ -156,9 +145,6 @@ describe('useAutoLogin', () => {
|
|||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
|
||||
// Swap to a "settings.autoConnect=true" world and rerender. Since
|
||||
// getSettings is a one-shot that already resolved with the old value,
|
||||
// changing its mockResolvedValue doesn't retroactively matter.
|
||||
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
|
||||
rerender();
|
||||
|
||||
|
|
|
|||
|
|
@ -13,32 +13,14 @@ export interface LoginFormValues {
|
|||
autoConnect?: boolean;
|
||||
}
|
||||
|
||||
// Auto-login is a *startup* concern — the persisted preference is consulted
|
||||
// once per JS session, after both stores have loaded. A logout within the
|
||||
// same session is an explicit user action; returning to /login should not
|
||||
// re-auto-connect (matches Cockatrice desktop behaviour). The flag is
|
||||
// module-scope so it persists across Login remounts and is naturally reset
|
||||
// on page refresh, which is the one time we do want another try.
|
||||
//
|
||||
// The flag tracks whether the *check* has run, not whether it *fired* — a
|
||||
// manual first login followed by a logout must not re-trigger auto-login
|
||||
// either, so the outcome of the check is irrelevant; only that it happened.
|
||||
//
|
||||
// Exported as a mutable object (rather than a bare `let`) so integration
|
||||
// tests can reset `startupCheckRan = false` between scenarios without
|
||||
// resorting to `vi.resetModules`, which is prohibitively slow in the full
|
||||
// suite. Production code only writes the flag from inside the effect.
|
||||
export const autoLoginSession = { startupCheckRan: false };
|
||||
export const autoLoginGate = { hasChecked: false };
|
||||
|
||||
// Deliberately does NOT subscribe to the settings / known-hosts stores —
|
||||
// user actions that change those stores (ticking the auto-connect checkbox,
|
||||
// picking a different host) are preference edits, not "log in now" signals.
|
||||
export function useAutoLogin(
|
||||
onLogin: (values: LoginFormValues) => void,
|
||||
connectionAttemptMade: boolean,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (autoLoginSession.startupCheckRan) {
|
||||
if (autoLoginGate.hasChecked) {
|
||||
return;
|
||||
}
|
||||
if (connectionAttemptMade) {
|
||||
|
|
@ -48,10 +30,10 @@ export function useAutoLogin(
|
|||
let cancelled = false;
|
||||
|
||||
Promise.all([getSettings(), getKnownHosts()]).then(([settings, hosts]) => {
|
||||
if (cancelled || autoLoginSession.startupCheckRan) {
|
||||
if (cancelled || autoLoginGate.hasChecked) {
|
||||
return;
|
||||
}
|
||||
autoLoginSession.startupCheckRan = true;
|
||||
autoLoginGate.hasChecked = true;
|
||||
|
||||
if (!settings.autoConnect) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ 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) => {
|
||||
|
|
@ -25,25 +24,20 @@ describe('useFireOnce hook', () => {
|
|||
return <button onClick={handleClickOnce} disabled={buttonIsDisabled}>{children}</button>
|
||||
}
|
||||
|
||||
// render the button
|
||||
const { getByRole } = render(
|
||||
<Button onClick={onClickWithPromise}>Click Me!</Button>
|
||||
);
|
||||
|
||||
//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);
|
||||
|
|
@ -53,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) => {
|
||||
|
|
@ -74,25 +67,20 @@ describe('useFireOnce hook', () => {
|
|||
)
|
||||
}
|
||||
|
||||
// render the form
|
||||
const { getByRole } = render(
|
||||
<Form onSubmit={onClickWithPromise} />
|
||||
);
|
||||
|
||||
//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);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,6 @@ import { App } from '@app/types';
|
|||
|
||||
import { createSharedStore, Loadable, useSharedStore } from './useSharedStore';
|
||||
|
||||
// Shared-store scope justification: multiple components on the login screen
|
||||
// read the same host list and selected host simultaneously (KnownHosts
|
||||
// dropdown, LoginForm's host-sync effect, useAutoLogin, and the Login
|
||||
// container's post-login write). Collapsing to useState inside one component
|
||||
// would duplicate Dexie reads and race on lastSelected updates — exactly the
|
||||
// bug we set out to fix.
|
||||
export interface KnownHostsValue {
|
||||
hosts: HostDTO[];
|
||||
selectedHost: HostDTO;
|
||||
|
|
@ -29,15 +23,12 @@ const normalize = async (hosts: HostDTO[]): Promise<KnownHostsValue> => {
|
|||
return { hosts, selectedHost: existing };
|
||||
}
|
||||
|
||||
// No row marked lastSelected yet (first-ever load after seeding, or legacy
|
||||
// data). Pin hosts[0] and persist so subsequent boots are deterministic.
|
||||
const selected = hosts[0];
|
||||
selected.lastSelected = true;
|
||||
await selected.save();
|
||||
return { hosts, selectedHost: selected };
|
||||
};
|
||||
|
||||
// Exported for integration-test reset; see settingsStore for the rationale.
|
||||
export const knownHostsStore = createSharedStore<KnownHostsValue>(async () => {
|
||||
const hosts = await loadAll();
|
||||
return normalize(hosts);
|
||||
|
|
@ -51,8 +42,6 @@ export type KnownHostsHook = Loadable<KnownHostsValue> & {
|
|||
remove: (id: number) => Promise<void>;
|
||||
};
|
||||
|
||||
// Guard for mutators. Mutators run outside React render, so we can't gate
|
||||
// them through the hook's status; peek + throw is the fail-fast alternative.
|
||||
const requireValue = (method: string): KnownHostsValue => {
|
||||
const current = store.peek();
|
||||
if (!current) {
|
||||
|
|
@ -127,6 +116,4 @@ export function useKnownHosts(): KnownHostsHook {
|
|||
return { ...state, select, add, update, remove };
|
||||
}
|
||||
|
||||
// Non-reactive one-shot accessor, mirroring getSettings. See the comment on
|
||||
// that export in useSettings.ts for the rationale.
|
||||
export const getKnownHosts = (): Promise<KnownHostsValue> => store.whenReady();
|
||||
|
|
|
|||
|
|
@ -3,15 +3,6 @@ import { App } from '@app/types';
|
|||
|
||||
import { createSharedStore, Loadable, useSharedStore } from './useSharedStore';
|
||||
|
||||
// First-time bootstrap: SettingDTO.get returns undefined when no row exists
|
||||
// for the app user yet (fresh install, or a user who has never hit the
|
||||
// settings code path before). We materialize a default DTO and persist it so
|
||||
// subsequent loads always see a non-null row.
|
||||
//
|
||||
// Exported as `settingsStore` so integration tests can call
|
||||
// `settingsStore.reset()` between scenarios — the module cache would
|
||||
// otherwise serve stale data across per-test Dexie resets. Production code
|
||||
// goes through `useSettings()` / `getSettings()` and doesn't touch this.
|
||||
export const settingsStore = createSharedStore<SettingDTO>(async () => {
|
||||
let loaded = await SettingDTO.get(App.APP_USER);
|
||||
if (!loaded) {
|
||||
|
|
@ -30,9 +21,6 @@ export function useSettings(): SettingsHook {
|
|||
const state = useSharedStore(store);
|
||||
|
||||
const update = async (patch: Partial<SettingDTO>) => {
|
||||
// Fail-fast if a caller tries to write before the initial load resolves.
|
||||
// Shouldn't happen in normal flow (the checkbox is gated on the hook's
|
||||
// ready status), so surface the bug loudly instead of silently no-oping.
|
||||
const current = store.peek();
|
||||
if (!current) {
|
||||
throw new Error('useSettings.update called before settings loaded');
|
||||
|
|
@ -45,8 +33,4 @@ export function useSettings(): SettingsHook {
|
|||
return { ...state, update };
|
||||
}
|
||||
|
||||
// Non-reactive one-shot accessor. Use this from code that wants the loaded
|
||||
// value exactly once and does NOT want to re-run when the user subsequently
|
||||
// edits their settings — e.g. the auto-login orchestrator, which consults
|
||||
// the persisted preference at startup only.
|
||||
export const getSettings = (): Promise<SettingDTO> => store.whenReady();
|
||||
|
|
|
|||
|
|
@ -12,27 +12,14 @@ export interface Loadable<T> {
|
|||
error?: Error;
|
||||
}
|
||||
|
||||
// @critical Two surfaces: subscribe (reactive) vs whenReady (one-shot).
|
||||
// See .github/instructions/webclient.instructions.md#shared-store-pattern.
|
||||
export interface SharedStore<T> {
|
||||
// Reactive surface: subscribe + snapshot back useSyncExternalStore so
|
||||
// consuming components re-render on every store update.
|
||||
subscribe: (cb: () => void) => () => void;
|
||||
getSnapshot: () => Loadable<T>;
|
||||
|
||||
// One-shot surface: whenReady resolves with the initial loaded value and
|
||||
// never fires again. Callers that only need "read once after init" (e.g.
|
||||
// the auto-login orchestrator) use this to avoid subscribing to updates
|
||||
// they don't care about — which would otherwise turn a user preference
|
||||
// toggle into a re-evaluation of startup logic.
|
||||
whenReady: () => Promise<T>;
|
||||
|
||||
// Mutator-side helpers, not for consumption inside render.
|
||||
setValue: (value: T) => void;
|
||||
peek: () => T | undefined;
|
||||
|
||||
// Clear cached state and the resolved readyPromise; the next subscribe /
|
||||
// whenReady call triggers a fresh load. In production nobody calls this;
|
||||
// integration tests use it to discard per-test Dexie state without
|
||||
// paying the cost of vi.resetModules across the whole dep graph.
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -41,9 +28,7 @@ export function createSharedStore<T>(load: () => Promise<T>): SharedStore<T> {
|
|||
const subscribers = new Set<() => void>();
|
||||
let loadStarted = false;
|
||||
|
||||
// whenReady is lazy: we only attach a promise once someone asks for one.
|
||||
// This avoids Node's unhandled-rejection bookkeeping for stores whose
|
||||
// loader fails but never had a whenReady caller.
|
||||
// Lazy to avoid unhandled-rejection bookkeeping when no caller awaits it.
|
||||
let readyPromise: Promise<T> | null = null;
|
||||
|
||||
const notify = () => {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@ import { createContext, useContext, useState, ReactNode } from 'react';
|
|||
import { WebClient } from '@app/websocket';
|
||||
import { createWebClientRequest, createWebClientResponse } from '@app/api';
|
||||
|
||||
// Exported so integration tests can inject the WebClient singleton built
|
||||
// by their shared setup without going through the production provider
|
||||
// (which would attempt to `new WebClient(...)` a second time and throw).
|
||||
export const WebClientContext = createContext<WebClient | null>(null);
|
||||
|
||||
export function WebClientProvider({ children }: { children: ReactNode }) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
// 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';
|
||||
|
||||
// jsdom doesn't provide ResizeObserver; react-window needs it.
|
||||
|
|
@ -14,9 +12,7 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
|
|||
} as any;
|
||||
}
|
||||
|
||||
// Mock Dexie globally to prevent IndexedDB initialization in jsdom.
|
||||
// Dexie eagerly opens IndexedDB on import, and jsdom's fake-indexeddb
|
||||
// is memory-intensive. No UI test needs a real database.
|
||||
// Dexie eagerly opens IndexedDB on import; jsdom's fake-indexeddb is memory-intensive.
|
||||
vi.mock('dexie', () => {
|
||||
const fakeTable = {
|
||||
mapToClass: () => {},
|
||||
|
|
@ -42,58 +38,11 @@ vi.mock('dexie', () => {
|
|||
return { default: FakeDexie, __esModule: true };
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
|
||||
beforeEach(() => {
|
||||
_locationDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
|
||||
_originalWebSocket = globalThis.WebSocket;
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<Enriched.GametypeMap>((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,7 +39,6 @@ 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<Enriched.LogGroups>((obj, log) => {
|
||||
const type = log.targetType as keyof Enriched.LogGroups;
|
||||
|
|
@ -59,12 +49,8 @@ export function normalizeLogs(logs: Data.ServerInfo_ChatMessage[]): Enriched.Log
|
|||
}, { 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;
|
||||
|
|
@ -72,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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof makeCard>[0] = {}) {
|
||||
|
|
@ -482,7 +479,6 @@ describe('2C: CARD_MOVED', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── 2D: Card mutations ────────────────────────────────────────────────────────
|
||||
|
||||
describe('2D: Card mutations', () => {
|
||||
function stateWithCardInZone(zoneName: string) {
|
||||
|
|
@ -587,7 +583,6 @@ describe('2D: Card mutations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── 2E: CARD_ATTR_CHANGED ─────────────────────────────────────────────────────
|
||||
|
||||
describe('2E: CARD_ATTR_CHANGED', () => {
|
||||
function stateWithCard() {
|
||||
|
|
@ -660,7 +655,6 @@ describe('2E: CARD_ATTR_CHANGED', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── 2F: CARD_COUNTER_CHANGED ─────────────────────────────────────────────────
|
||||
|
||||
describe('2F: CARD_COUNTER_CHANGED', () => {
|
||||
function stateWithCard(existingCounters: any[] = []) {
|
||||
|
|
@ -711,7 +705,6 @@ describe('2F: CARD_COUNTER_CHANGED', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── 2G: Arrows ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('2G: Arrows', () => {
|
||||
it('ARROW_CREATED → inserts arrowInfo into player.arrows keyed by id', () => {
|
||||
|
|
@ -745,7 +738,6 @@ describe('2G: Arrows', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── 2H: Player counters ───────────────────────────────────────────────────────
|
||||
|
||||
describe('2H: Player counters', () => {
|
||||
it('COUNTER_CREATED → inserts counterInfo into player.counters keyed by id', () => {
|
||||
|
|
@ -809,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', () => {
|
||||
|
|
@ -963,7 +954,6 @@ describe('2I: Zone operations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── 2J: Turn / phase / chat ───────────────────────────────────────────────────
|
||||
|
||||
describe('2J: Turn, phase, and chat', () => {
|
||||
it('ACTIVE_PLAYER_SET → sets game.activePlayerId', () => {
|
||||
|
|
@ -998,7 +988,6 @@ describe('2J: Turn, phase, and chat', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── 2K: No-op / passthrough actions ──────────────────────────────────────────
|
||||
|
||||
describe('2K: No-op / passthrough actions', () => {
|
||||
it('ZONE_SHUFFLED → returns state unchanged (identity)', () => {
|
||||
|
|
@ -1026,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -177,7 +177,6 @@ export const gamesSlice = createSlice({
|
|||
}
|
||||
},
|
||||
|
||||
// ── Card manipulation ────────────────────────────────────────────────────
|
||||
|
||||
cardMoved: (
|
||||
state,
|
||||
|
|
@ -343,7 +342,6 @@ export const gamesSlice = createSlice({
|
|||
}
|
||||
},
|
||||
|
||||
// ── Arrows ───────────────────────────────────────────────────────────────
|
||||
|
||||
arrowCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateArrow }>) => {
|
||||
const { gameId, playerId, data } = action.payload;
|
||||
|
|
@ -361,7 +359,6 @@ export const gamesSlice = createSlice({
|
|||
}
|
||||
},
|
||||
|
||||
// ── Player counters ───────────────────────────────────────────────────────
|
||||
|
||||
counterCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateCounter }>) => {
|
||||
const { gameId, playerId, data } = action.payload;
|
||||
|
|
@ -387,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;
|
||||
|
|
@ -444,7 +440,6 @@ export const gamesSlice = createSlice({
|
|||
}
|
||||
},
|
||||
|
||||
// ── Turn / phase ──────────────────────────────────────────────────────────
|
||||
|
||||
activePlayerSet: (state, action: PayloadAction<{ gameId: number; activePlayerId: number }>) => {
|
||||
const game = state.games[action.payload.gameId];
|
||||
|
|
@ -467,7 +462,6 @@ export const gamesSlice = createSlice({
|
|||
}
|
||||
},
|
||||
|
||||
// ── Chat ──────────────────────────────────────────────────────────────────
|
||||
|
||||
gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string; timeReceived: number }>) => {
|
||||
const { gameId, playerId, message, timeReceived } = action.payload;
|
||||
|
|
@ -481,7 +475,6 @@ export const gamesSlice = createSlice({
|
|||
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 }>) => {},
|
||||
|
|
|
|||
|
|
@ -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 } }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +230,6 @@ describe('USER_LEFT', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── SORT_GAMES ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SORT_GAMES', () => {
|
||||
it('updates sortGamesBy on state (sorting itself is now derived in selectors)', () => {
|
||||
|
|
@ -251,7 +243,6 @@ describe('SORT_GAMES', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── REMOVE_MESSAGES ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('REMOVE_MESSAGES', () => {
|
||||
it('removes messages starting with "name:" up to amount, in reverse scan order', () => {
|
||||
|
|
@ -294,7 +285,6 @@ describe('REMOVE_MESSAGES', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── GAME_CREATED ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GAME_CREATED', () => {
|
||||
it('returns state unchanged', () => {
|
||||
|
|
@ -304,7 +294,6 @@ describe('GAME_CREATED', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── JOINED_GAME ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('JOINED_GAME', () => {
|
||||
it('sets joinedGameIds[roomId][gameId] = true', () => {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -96,8 +95,6 @@ export const roomsSlice = createSlice({
|
|||
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,
|
||||
|
|
@ -146,8 +142,6 @@ export const roomsSlice = createSlice({
|
|||
},
|
||||
|
||||
sortGames: (state, action: PayloadAction<{ roomId: number; field: App.GameSortField; order: App.SortDirection }>) => {
|
||||
// Sort is derived in selectors; the reducer stores the sort config.
|
||||
// roomId is passed through for future per-room sorting support.
|
||||
const { field, order } = action.payload;
|
||||
state.sortGamesBy = { field, order };
|
||||
},
|
||||
|
|
@ -160,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;
|
||||
|
|
@ -184,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 }>) => {},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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> = {}
|
||||
): Enriched.LoginSuccessContext {
|
||||
overrides: Partial<WebsocketTypes.LoginSuccessContext> = {}
|
||||
): WebsocketTypes.LoginSuccessContext {
|
||||
return {
|
||||
hashedPassword: 'hash',
|
||||
...overrides,
|
||||
|
|
@ -126,8 +127,8 @@ export function makeLoginSuccessContext(
|
|||
}
|
||||
|
||||
export function makePendingActivationContext(
|
||||
overrides: Partial<Enriched.PendingActivationContext> = {}
|
||||
): Enriched.PendingActivationContext {
|
||||
overrides: Partial<WebsocketTypes.PendingActivationContext> = {}
|
||||
): WebsocketTypes.PendingActivationContext {
|
||||
return {
|
||||
host: 'localhost',
|
||||
port: '4747',
|
||||
|
|
@ -143,7 +144,7 @@ export function makeServerState(overrides: Partial<ServerState> = {}): ServerSta
|
|||
ignoreList: {},
|
||||
status: {
|
||||
connectionAttemptMade: false,
|
||||
state: Enriched.StatusEnum.DISCONNECTED,
|
||||
state: WebsocketTypes.StatusEnum.DISCONNECTED,
|
||||
description: null,
|
||||
},
|
||||
info: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Actions } from './server.actions';
|
||||
import { Data, Enriched } 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: Enriched.StatusEnum.CONNECTED, description: 'connected' };
|
||||
const status = { state: WebsocketTypes.StatusEnum.CONNECTED, description: 'connected' };
|
||||
expect(Actions.updateStatus({ status })).toEqual({ type: Types.UPDATE_STATUS, payload: { status } });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { Data, Enriched } from '@app/types';
|
||||
import { Data } from '@app/types';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import {
|
||||
makeBanHistoryItem,
|
||||
|
|
@ -106,9 +105,9 @@ describe('Dispatch', () => {
|
|||
});
|
||||
|
||||
it('updateStatus dispatches Actions.updateStatus({ status: { state, description } })', () => {
|
||||
Dispatch.updateStatus(Enriched.StatusEnum.CONNECTED, 'ok');
|
||||
Dispatch.updateStatus(WebsocketTypes.StatusEnum.CONNECTED, 'ok');
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
Actions.updateStatus({ status: { state: Enriched.StatusEnum.CONNECTED, description: 'ok' } })
|
||||
Actions.updateStatus({ status: { state: WebsocketTypes.StatusEnum.CONNECTED, description: 'ok' } })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Actions } from './server.actions';
|
||||
import { store } from '..';
|
||||
import { 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: Enriched.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: () => {
|
||||
|
|
|
|||
|
|
@ -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: Enriched.StatusEnum;
|
||||
state: WebsocketTypes.StatusEnum;
|
||||
}
|
||||
|
||||
export interface ServerStateInfo {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Data, Enriched } from '@app/types';
|
||||
import { Data } from '@app/types';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { serverReducer, MAX_USER_MESSAGES } from './server.reducer';
|
||||
import { Actions } from './server.actions';
|
||||
|
|
@ -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(Enriched.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: Enriched.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: Enriched.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: Enriched.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(Enriched.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', () => {
|
||||
|
|
@ -273,7 +268,6 @@ describe('Logs', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Messaging ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Messaging', () => {
|
||||
it('USER_MESSAGE → uses receiverName as key when current user is sender', () => {
|
||||
|
|
@ -326,7 +320,6 @@ describe('Messaging', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── User Info & Notifications ─────────────────────────────────────────────────
|
||||
|
||||
describe('User Info & Notifications', () => {
|
||||
it('GET_USER_INFO → adds userInfo keyed by name', () => {
|
||||
|
|
@ -352,7 +345,6 @@ describe('User Info & Notifications', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Moderation ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Moderation', () => {
|
||||
it('BAN_FROM_SERVER → sets banUser', () => {
|
||||
|
|
@ -401,7 +393,6 @@ describe('Moderation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── ADJUST_MOD ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ADJUST_MOD', () => {
|
||||
const baseUserLevel = UserLevelFlag.IsUser | UserLevelFlag.IsRegistered | UserLevelFlag.IsModerator | UserLevelFlag.IsJudge;
|
||||
|
|
@ -459,7 +450,6 @@ describe('ADJUST_MOD', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Replays ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Replays', () => {
|
||||
it('REPLAY_LIST → replaces replays map keyed by gameId', () => {
|
||||
|
|
@ -512,7 +502,6 @@ describe('Replays', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Deck Storage ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Deck Storage', () => {
|
||||
it('BACKEND_DECKS → sets backendDecks', () => {
|
||||
|
|
@ -675,7 +664,6 @@ describe('Deck Storage', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── GAMES_OF_USER ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GAMES_OF_USER', () => {
|
||||
it('stores normalized games keyed by userName and gameId', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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';
|
||||
|
|
@ -73,7 +74,7 @@ const initialState: ServerState = {
|
|||
|
||||
status: {
|
||||
connectionAttemptMade: false,
|
||||
state: Enriched.StatusEnum.DISCONNECTED,
|
||||
state: WebsocketTypes.StatusEnum.DISCONNECTED,
|
||||
description: null
|
||||
},
|
||||
info: {
|
||||
|
|
@ -177,7 +178,7 @@ export const serverSlice = createSlice({
|
|||
state.status.state = status.state;
|
||||
state.status.description = status.description;
|
||||
|
||||
if (status.state === Enriched.StatusEnum.DISCONNECTED) {
|
||||
if (status.state === WebsocketTypes.StatusEnum.DISCONNECTED) {
|
||||
state.status.connectionAttemptMade = false;
|
||||
}
|
||||
},
|
||||
|
|
@ -403,10 +404,10 @@ export const serverSlice = createSlice({
|
|||
},
|
||||
|
||||
// Signal-only action types — no state mutation, defined so type strings are generated
|
||||
accountAwaitingActivation: (_state, _action: PayloadAction<{ options: Enriched.PendingActivationContext }>) => {},
|
||||
accountAwaitingActivation: (_state, _action: PayloadAction<{ options: WebsocketTypes.PendingActivationContext }>) => {},
|
||||
accountActivationFailed: (_state) => {},
|
||||
accountActivationSuccess: (_state) => {},
|
||||
loginSuccessful: (_state, _action: PayloadAction<{ options: Enriched.LoginSuccessContext }>) => {},
|
||||
loginSuccessful: (_state, _action: PayloadAction<{ options: WebsocketTypes.LoginSuccessContext }>) => {},
|
||||
loginFailed: (_state) => {},
|
||||
connectionFailed: (_state) => {},
|
||||
testConnectionSuccessful: (_state) => {},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import {
|
|||
makeServerState,
|
||||
makeUser,
|
||||
} from './__mocks__/server-fixtures';
|
||||
import { Data, Enriched } 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: Enriched.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: Enriched.StatusEnum.LOGGED_IN, description: null } });
|
||||
expect(Selectors.getState(rootState(state))).toBe(Enriched.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: Enriched.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: Enriched.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: Enriched.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: Enriched.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: Enriched.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);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { Data, Enriched } 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 === Enriched.StatusEnum.LOGGED_IN
|
||||
(state): boolean => state === WebsocketTypes.StatusEnum.LOGGED_IN
|
||||
),
|
||||
|
||||
/** True when the currently logged-in user has the IsModerator level flag. */
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,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;
|
||||
}
|
||||
|
||||
|
|
@ -41,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;
|
||||
|
|
@ -72,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;
|
||||
|
|
@ -114,39 +85,3 @@ export interface LogGroups {
|
|||
game: ServerInfo_ChatMessage[];
|
||||
chat: ServerInfo_ChatMessage[];
|
||||
}
|
||||
|
||||
// ── Websocket re-exports ─────────────────────────────────────────────────────
|
||||
// Source of truth lives in @app/websocket. Re-exported here so app code can
|
||||
// reach these via the Enriched.* namespace without importing @app/websocket.
|
||||
|
||||
export { StatusEnum, WebSocketConnectReason } from '@app/websocket';
|
||||
|
||||
export type {
|
||||
GameEventMeta,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
import type { StatusEnum } from './enriched';
|
||||
|
||||
export interface ServerStatus {
|
||||
status: StatusEnum;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class Host {
|
||||
id?: number;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 '.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 './';
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -18,8 +18,16 @@ import { Mock } from 'vitest';
|
|||
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
|
||||
import { WebClient } from '../../WebClient';
|
||||
import * as SessionIndexMocks from './';
|
||||
import { 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> = {}): Enriched.LoginConnectOptions => ({
|
||||
const makeLoginOpts = (overrides: Partial<LoginConnectOptions> = {}): LoginConnectOptions => ({
|
||||
...baseTransport,
|
||||
userName: 'alice',
|
||||
reason: Enriched.WebSocketConnectReason.LOGIN,
|
||||
reason: WebSocketConnectReason.LOGIN,
|
||||
...overrides,
|
||||
});
|
||||
const makeRegisterOpts = (
|
||||
overrides: Partial<Enriched.RegisterConnectOptions> = {}
|
||||
): Enriched.RegisterConnectOptions => ({
|
||||
overrides: Partial<RegisterConnectOptions> = {}
|
||||
): RegisterConnectOptions => ({
|
||||
...baseTransport,
|
||||
userName: 'alice',
|
||||
password: 'pw',
|
||||
email: 'a@b.com',
|
||||
country: 'US',
|
||||
realName: 'Al',
|
||||
reason: Enriched.WebSocketConnectReason.REGISTER,
|
||||
reason: WebSocketConnectReason.REGISTER,
|
||||
...overrides,
|
||||
});
|
||||
const makeActivateOpts = (
|
||||
overrides: Partial<Enriched.ActivateConnectOptions> = {}
|
||||
): Enriched.ActivateConnectOptions => ({
|
||||
overrides: Partial<ActivateConnectOptions> = {}
|
||||
): ActivateConnectOptions => ({
|
||||
...baseTransport,
|
||||
userName: 'alice',
|
||||
token: 'tok',
|
||||
reason: Enriched.WebSocketConnectReason.ACTIVATE_ACCOUNT,
|
||||
reason: WebSocketConnectReason.ACTIVATE_ACCOUNT,
|
||||
...overrides,
|
||||
});
|
||||
const makeForgotRequestOpts = (): Enriched.PasswordResetRequestConnectOptions => ({
|
||||
const makeForgotRequestOpts = (): PasswordResetRequestConnectOptions => ({
|
||||
...baseTransport,
|
||||
userName: 'alice',
|
||||
reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_REQUEST,
|
||||
reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST,
|
||||
});
|
||||
const makeForgotChallengeOpts = (): Enriched.PasswordResetChallengeConnectOptions => ({
|
||||
const makeForgotChallengeOpts = (): PasswordResetChallengeConnectOptions => ({
|
||||
...baseTransport,
|
||||
userName: 'alice',
|
||||
email: 'a@b.com',
|
||||
reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE,
|
||||
reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE,
|
||||
});
|
||||
const makeForgotResetOpts = (): Enriched.PasswordResetConnectOptions => ({
|
||||
const makeForgotResetOpts = (): PasswordResetConnectOptions => ({
|
||||
...baseTransport,
|
||||
userName: 'alice',
|
||||
token: 'tok',
|
||||
newPassword: 'newpw',
|
||||
reason: Enriched.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: Enriched.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', () => {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,6 @@ beforeEach(() => {
|
|||
(passwordSaltSupported as Mock).mockReturnValue(0);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
describe('accountEdit', () => {
|
||||
it('sends Command_AccountEdit with correct params', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue