# Scrabble Game — Testing

How the project is tested and the gate every stage must pass. Read before adding
tests or touching CI.

## Layers

- **Go unit tests** — table-driven where it helps; `testing` + standard library.
  Every functional change ships with regression coverage. Run:
  `go test -count=1 ./backend/... ./pkg/... ./gateway/...` (the module list grows
  with the workspace).
- **Integration** — Postgres-backed tests behind the `integration`
  build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They
  live in `backend/internal/inttest` and run with
  `go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded
  by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow.
- **UI** — Vitest (unit) + Playwright
  (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
  the FlatBuffers codecs (friend list, invitation, stats), the win-rate
  derivation, the GCG share/copy/download choice and the **net-state reducer**
  (`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
  hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
  plus Playwright specs against the
  mock for the friends screen (code issue/redeem, accept a request), the lobby
  invitations section, the stats screen, profile editing, and the export chooser's
  finished-only visibility + its signed-URL download flow (route-intercepted). The
  **offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
  end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
  auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
  Stats tab), self-heals back online, then creates and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
  rack is deterministic and the human can tap out a precomputed opening), asserting the
  robot's real reply and the IndexedDB replay after a reload. A local game needs a real
  dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
  build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
  CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
  the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
  production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects
  `window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare
  `Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
  in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
  (`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
  `window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
  **unified lobby** — and Profile hides the Telegram/VK link buttons.
  The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
  hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
  dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
  update_required` mapping.
- **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
  parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
  assert the **hard** tier — a too-old `Execute` returns `result_code = "update_required"` (and the op
  handler never ran), a too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min /
  unparseable header / equal version all pass (fail-open) — and the **soft** tier (`TestExecuteUpdateRecommended`):
  a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
  too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
  unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
  minimum.
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
  `ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
  real self-played 35-move game) must rasterize to a plausible PNG
  (`pnpm -C renderer test`). The drawing module's pure parts (scoresheet, layout,
  notation) stay unit-tested in `ui/` — the sidecar test guards only the
  skia/bundling seam. Pixel goldens are deliberately avoided (fonts differ across
  hosts).
- **Local-eval conformance** — the client's on-device move preview (the ported
  dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the
  authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit
  golden vectors from the release dictionaries — every stored word plus a battery of
  plays across both cross-word rules and all variants, each with the engine's
  legality, score, words and inferred direction. The gated Vitest suites
  (`ui/src/lib/dict/*.parity.test.ts`, skipped without their `DICT_*` env) replay
  them and must agree exactly; the `conformance` CI job runs the whole loop, so a
  drift in the port fails the merge.
- **Engine** — correctness of scoring and move generation is owned
  by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on
  top of the embedded solver: per-variant smoke tests (load all three committed
  DAWGs and validate a known word, including Эрудит), bag draw/return determinism
  and exchange accounting, the `Game` end-conditions (empty bag with an empty
  rack, and six scoreless turns) with end-game rack scoring, and
  **dictionary-independent history replay** (`ReplayBoard` reproduces a full
  greedy game's final board from decoded records alone), and the **resignation
  win/loss rule** (the resigner keeps their score yet loses). The engine tests
  read the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver`
  checkout) and fail loudly when it is absent.
- **Game domain** — `backend/internal/game` adds pure unit tests
  (the GCG writer, the away-window / effective-deadline boundaries, the hint
  budget, the live-game cache and per-game lock, payload round-trips) plus
  Postgres-backed integration tests in `inttest` (full lifecycle to a natural
  end, **journal-replay equivalence**, the turn-timeout sweep with away-window
  grace, resign win/loss and statistics, the hint allowance-then-wallet policy,
  word-check and complaint capture, and per-game-lock serialisation). It also covers
  the engine's **multi-player drop-out** cases (continue after one resign,
  last-survivor win, the tile-disposition bag effect) and a domain integration test
  for a 3-player **timeout that continues**, and the engine's `Candidates`
  ranked/decoded test.
- **Social & lobby** — `backend/internal/social` unit-tests the chat
  **content filter** (links/emails/phones plus obfuscated forms) and
  `backend/internal/lobby` unit-tests the in-memory **matchmaker** (FIFO pairing,
  cancel, per-variant pools, plus the **robot substitution** reaper and
  `Poll` delivery) with fake game-creator and robot-provider seams. Postgres-backed
  `inttest` covers the friend request/accept lifecycle with the block/toggle guards,
  the per-user block (and its severing of friendships), chat post/list with the IP,
  content and block-visibility rules, the nudge turn/rate-limit rules, the
  invitation flow (all-accept starts the game, decline cancels, lazy expiry,
  inviter-only cancel), and the email confirm-code flow (request/confirm, taken
  email, expiry and attempt-cap, and the guest-until-confirmed login) with a fixture
  mailer. The branded email template render (en/ru subject, code and body) and the
  per-recipient send-rate limiter (cooldown + hourly cap) are pure account-package
  unit tests, needing no database. It also covers the
  **befriend-an-opponent** gate (a request needs a shared game), the **permanent
  decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
  self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
  the GCG **finished-only** gate.
- **Robot** — `backend/internal/robot` unit-tests the pure strategy:
  the ≈ 40% play-to-win split over many seeds, the right-skewed move-delay
  (bounds, ~10-min median, determinism), the margin selection (win/lose, in-band
  and out-of-band fallbacks, no-play exchange/pass), the sleep window with drift
  and the midnight wrap, and mix restart-stability. Postgres-backed `inttest`
  drives a robot through a full auto-match to a natural end (asserting a robot
  statistics row), the matchmaker substitution end-to-end (enqueue → reap →
  `[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge.
- **Gateway & contracts** — `backend/internal/notify` unit-tests the
  hub fan-out (delivery, overflow drop, unsubscribe) and the FlatBuffers event
  constructors (payload round-trip). `gateway/...` unit-tests are hermetic (no
  real network — an `httptest` fake backend and fixtures): the Telegram initData
  HMAC validator (genuine, tampered, wrong-token, stale), the session cache
  (hit/miss/fallback, TTL re-resolve, invalidate), the rate limiter (burst,
  per-key isolation, per-window), the push hub (per-user routing, overflow,
  unsubscribe), the transcode round-trips (FlatBuffers↔JSON, X-User-ID
  forwarding, nested GameView, domain-code surfacing), the admin Basic-Auth
  reverse proxy (401 / forward), and a full Connect `Execute` path end to end
  (guest auth, unauthenticated rejection, unknown message type). The
  edge-hardening cases: an oversized `Execute` payload is refused
  (`resource_exhausted`, the `GATEWAY_MAX_BODY_BYTES` cap), a limiter rejection
  lands in `gateway_rate_limited_total{class}` and the rejection tracker
  (drain/aggregate unit tests), the report POST reaches
  `/api/v1/internal/ratelimit/report` with the agreed JSON shape, the `/_gm`
  mount is 429-guarded by the per-IP admin class, and the gateway's `/`
  308-redirects to `/app/` (the landing left the embed). The backend covers
  the **guest** lifecycle (a guest plays an auto-match to a natural end yet accrues
  no statistics) and the **email-as-login** flow (request/verify, returning user)
  in `inttest`. Gateway transcode round-trips cover the social/account
  operations (friends list, friend code issue/redeem, invitation create, stats, GCG,
  the profile-update away round-trip) and a `notify`-event constructor round-trip.
- **Admin & dictionary ops** — `backend/internal/adminconsole` unit-tests
  the template renderer over every page plus the embedded asset; `backend/internal/engine`
  adds the **dictionary** cases (`LoadAvailable` loads only the present variants,
  `OpenWithVersions` scans version subdirectories and skips the staging area, `DiffWords`
  decodes the words added/removed between two versions); `backend/internal/dictadmin`
  unit-tests the release-archive validation and hardened extraction (path-traversal,
  symlink, oversize and entry-count rejection, immutable versions); `backend/internal/server` unit-tests the console's
  **same-origin** CSRF guard; the gateway adds the **verbatim `/_gm` Basic-Auth proxy**
  (401 / forward, path preserved) and the h2c **console mount** (routed when configured,
  404 when not). Postgres-backed `inttest` drives the **complaint resolution →
  dictionary-change pipeline** (file → resolve with a disposition → pending change → mark
  applied), the **dictionary update flow** (upload a release archive over HTTP → preview →
  install → the new version becomes active and resident, persists across a restart, pins new
  games while in-progress games keep theirs, and is immutable), the admin **list/count** read
  queries, and the **/_gm console over HTTP** (pages render; a resolve POST needs a
  same-origin header). `ratewatch` has
  unit tests (window accumulation, the auto-flag threshold + expiry, the bounded
  episode map), the account-store **high-rate flag round-trip** (set-once / clear /
  re-flag) and a console flow in `inttest`: a gateway report auto-flags the account,
  the **Throttled** page shows the episode and the flagged queue, the user card
  carries the marker and the CSRF-guarded **Clear** reverses it.
- **Observability & performance** — `pkg/telemetry` unit-tests the exporter
  selection (`none`/`stdout`/`otlp` build providers; OTLP constructs with no collector;
  the nil-runtime fallback). The domain metrics are exercised through a manual
  `sdkmetric` reader: `backend/internal/game` and `…/social` assert the counters and
  histograms record with the right `variant`/`kind` attributes, and
  `gateway/internal/connectsrv` asserts `edge_request_duration` by `message_type`/
  `result`. Config tests cover the new telemetry env vars (backend/gateway/connector —
  `otlp` now accepted, an unsupported exporter rejected) and the guest-reaper knobs.
  Postgres-backed `inttest` drives the **guest reaper** end to end (an abandoned guest is
  reaped; a too-young guest, a seated guest and a durable account are kept).
- **Load test & resource baseline** — a reusable `loadtest/` module
  (`scrabble/loadtest`) is the stress/load harness. It **seeds** a large account
  population with pre-created sessions directly in Postgres (token hashes matching
  `backend/internal/session`), **drives** virtual players through the edge protocol —
  real games assembled via invitations, **mid-ranked** legal moves generated locally by
  the embedded `scrabble-solver` (the edge carries no board, so the client replays
  history) — plus a fraction of nudge/chat/check-word/draft/profile/stats ops, and a
  **gateway-hammer** that verifies the rate limiter. Its own Go unit tests cover the pure
  pieces (token hashing, board replay vs. `board.Parse`, rack reconstruction, mid-rank
  selection, the report); the DAWG-backed move test runs under `BACKEND_DICT_DIR` (as the
  engine tests do). It is **not** part of the per-PR suite's behavioural assertions: it
  runs ad hoc as a one-shot container against the contour, producing a trip report (bugs
  + a per-container resource profile) read off the **otelcol `docker_stats` +
  postgres_exporter** Grafana dashboard on the contour. The findings — including the
  `game.evaluate` hot-path model and the gateway→backend connection-pool fix — are written
  up in [`REPORT.md`](../loadtest/REPORT.md). See [`../loadtest/README.md`](../loadtest/README.md).
- **User feedback** — `internal/feedback` unit tests cover the attachment allow-list /
  content-type and the channel normaliser; the UI covers `detectChannel`, the attachment gate and
  the feedback wire round-trip (`channel` / `feedback` / `codec` tests) plus a Playwright e2e
  (submit → confirm + resend blocked; an operator reply raises the badge and shows on the screen).
  The gateway `connectsrv` test asserts the **guest gate** (a guest's `feedback.submit` →
  `guest_forbidden` with no backend call; a non-gated authenticated op still passes). Postgres-backed
  `inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
  read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
  delete / delete-all. The admin-console render test renders the feedback list + detail pages.
- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device /
  emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and
  store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in
  **airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a
  **2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the
  root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on**
  lights up online play (a server guest is established); Profile offers **email** sign-in but no
  Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action
  raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by
  eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`).

## Principles

- A green run must not depend on cached state: use `-count=1` in CI.
- Tests that need infrastructure fail loudly (`t.Fatal`) when it is unavailable
  rather than silently skipping coverage.
- No network or real platform calls in unit tests; validate platform
  credentials behind an interface seam and test with fixtures.

## CI gate

Every change is exercised on `gitea.iliadenisov.ru` before it is merged:

1. Commit the change on its `feature/*` branch.
2. Push to `origin`.
3. Watch the run to completion — never hand-roll a poll loop:
   `python3 ~/.claude/bin/gitea-ci-watch.py` (launch in the background).
4. Only after every workflow that fired is green may the change be merged.
