Merge pull request 'Test/frontend coverage' (#4) from test/frontend-coverage into main
frontend-ci / lint (push) Successful in 25s
frontend-ci / typecheck (push) Successful in 23s
frontend-ci / secrets-scan (push) Successful in 11s
frontend-ci / sast (push) Successful in 11s
frontend-ci / fs-scan (push) Successful in 15s
frontend-ci / test (push) Successful in 35s
frontend-ci / build (push) Successful in 44s
frontend-ci / push (push) Successful in 39s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
TehRiehlDeal committed 2026-09-12 03:07:45 -07:00
commit 4e0eb8cd06
192 files changed
+25055 -1510

No files matched your search

+32 -1
View File
@@ -30,6 +30,22 @@ jobs:
- run: npm ci
- run: npx tsc -b
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
# With coverage: vite.config.ts codifies the 90% thresholds and this is
# what enforces them. The suite cleared the bar in WP-19 (from 14%), so
# a change that drops below it now fails the job rather than a number
# in a report nobody reads.
- run: npm run test:cov
secrets-scan:
runs-on: ubuntu-latest
steps:
@@ -111,6 +127,19 @@ jobs:
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "HARBOR_HOST=${HARBOR_HOST}, VERSION=${VERSION}, SHA_SHORT=${SHA_SHORT}"
- name: Refuse to reuse an existing version tag in git
run: |
set -u
# The back-push step below tags HEAD as v${VERSION}. If that tag is
# already on origin it points at an older commit, and `git tag`
# would fail silently, leaving the version attached to the wrong
# code. Harbor has its own guard next; this is the git half.
if git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null; then
echo "::error::Tag v${VERSION} already exists on origin. Bump package.json before merging."
exit 1
fi
echo "Tag v${VERSION} not yet on origin — proceeding."
- name: Refuse to overwrite an existing version tag in Harbor
env:
HARBOR_USERNAME: ${{ secrets.MOVIELOOP_USERNAME }}
@@ -189,7 +218,9 @@ jobs:
set -eu
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@tehriehldeal.com"
git tag "v${VERSION}" -m "${IMAGE_NAME} v${VERSION}" 2>/dev/null || true
# Guarded above: v${VERSION} was absent from origin when this run
# started, so creating it cannot silently no-op.
git tag "v${VERSION}" -m "${IMAGE_NAME} v${VERSION}"
ORIGIN="$(git remote get-url origin | sed -E "s#https?://([^/]+)#https://${GITEA_TOKEN}@\1#")"
# A failed tag push shouldn't undo a successful image push — log
# and proceed so we don't poison subsequent retries.
+4
View File
@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Test output
coverage
.vitest
+377
View File
@@ -9,6 +9,383 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.2.2] - 2026-09-12
Found by playing the app end to end against a clean-install development stack
before the first push - a Quick Play, a Custom Game loop closed and scored, a
hint, an undo, and every anonymous route.
### Fixed
- **No more stale results after picking an actor or movie.** Selecting an
actor clears the input and flips the search to movie mode, but the 300ms
debounce still held the actor's name, so a *movie* search for "Daniel Craig"
ran and opened a listbox of results under an empty box. An empty input now
means nothing to search, whatever the debounce holds.
- **The hint button says what a hint costs.** It read "-150 pts"; the server
charges 200 (150 is the duplicate penalty).
- **A day with no daily challenges yet reads as what it is.** The API answers
404 before the midnight job has run - a fresh install, or a failed job - and
the page called that "Failed to load today's challenges", which reads as an
outage. It now says they have not been generated yet and when they will be.
- Requests the page abandoned on purpose (navigating away mid-fetch aborts
them) are no longer logged as `[API Error] canceled` - two per page change,
burying the real ones.
## [2.2.1] - 2026-09-12
The frontend test foundation (WP-19: the frontend half of L1), and the defects
it turned up.
### Fixed
- **Joining an open Game Night room that refuses you shows why.** The error was
set but only rendered inside the password prompt, which a passwordless join
never opens - WP-10 fixed the password path and left this one silent.
- **The remove-friend button has an accessible name** ("Remove <name> as a
friend"). It rendered only an icon, the one icon-only control the
accessibility pass missed; a screen reader announced "button" next to a
destructive action.
- **Signing out from Profile lands on the home page**, as the button intends.
The page's own auth guard re-ran the moment the user cleared and sent them
to the sign-in screen instead.
- **The sign-in page redirects someone who is already signed in** to their
profile, and **says so when the session expired**: the API client has sent
expired sessions to `/login?expired=1` since WP-10, and nothing read it, so
being signed out by an expired token looked identical to signing out.
- **Starting a game reports failure instead of losing it.** Quick Play and
Endless awaited the TMDB pick with no catch - a failed request, or a page
with fewer than two usable films, was an unhandled rejection with nothing on
screen - and the button only disabled itself during `startGame`, so a second
click during the pick started a second game. Every start page (Quick Play,
Endless, Custom, Daily) also navigated to `/game` whether or not the game
had started; a failure bounced off that page's idle redirect with the
store's message never displayed. `startGame` now reports `'started'` or
`'failed'`, and each page shows the reason and stays put.
### Changed
- `npm run test:cov` is what CI runs: the 90% thresholds codified in WP-14 are
enforced, having been cleared - 14% to 99% lines, 12% to 97% branches, 105
to 1,112 tests across 93 files. Every source file is above the bar on its
own; there are no per-file exceptions.
- The pair picker shuffles by random key instead of an in-place Fisher-Yates,
removing three index guards that could not be reached.
## [2.2.0] - 2026-09-11
Repository hygiene (WP-17: L2, L4, M9, M35, the frontend half of the audit's
section 3 prune list). Minor: push notifications become usable, and nothing a
player could reach was removed. The admin console's maintenance jobs need
backend 3.0.0.
### Added
- **Push notifications can be turned on.** Profile → Notifications has the
toggle. The whole browser half of the push stack - the service worker's
`push` and `notificationclick` handlers, the subscribe/unsubscribe calls,
the server's VAPID key - had shipped for months with nothing calling it, so
no browser ever held a subscription (M35). The prompt is asked from a click,
as browsers require, and a refusal or a server without VAPID keys is
explained in place rather than failing silently. If a subscription cannot be
registered with the server, the browser-side one is rolled back so the two
never disagree.
- The admin console's "Backfill Game Owners" card, for the job that re-attaches
legacy games to their players. It reports how many were ambiguous - more
than one player matched, so the server made no guess - alongside the count
it re-attached.
- Tests for the push library, the subscription hook, the notifications
section and the pair picker (45 tests; 105 in total).
### Changed
- Admin maintenance jobs continue where they left off. Each one processes a
page and, when more remains, the button becomes **Continue** and passes the
server's cursor back. The three near-identical job cards are one component.
The recalculate card also stopped printing "undefined leaderboard entries":
its type declared a field the server had not sent for several releases.
- Endless mode and quick play pick pairs through one franchise-aware picker.
Endless had its own copy with no franchise check, so it could serve *The
Fellowship of the Ring* against *The Two Towers*; both copies also shuffled
with `sort(() => Math.random() - 0.5)`, which is neither uniform nor a valid
comparator.
- The four game screens share one hook for the store wiring and label
derivation they had each copied, and one search panel; the review page's
read-only chain display is the live `ChainDisplay` in a static mode rather
than a second component that had drifted from it. Verified byte-identical
DOM across 21 rendered states before and after.
- `formatTime` and error-message extraction each exist once (`lib/format`,
`lib/error`) instead of five and three times.
- `noUncheckedIndexedAccess` is on. Indexing now types as possibly undefined
and the code handles it, with no non-null assertions added. Along the way,
taking an initial with `[0]` became `.charAt(0)`, which also stops an empty
display name from throwing.
- The package is named `movie-loop-frontend`; it had kept the scaffold's
`frontend-temp`.
- CI refuses to release a version whose `v<version>` git tag already exists on
origin, before it builds anything, for the same reason the backend does.
- The stock Vite README is replaced with one that describes this app.
### Fixed
- *Whiplash* in the preset pairs is TMDB id 244786; the table had 274870, a
different film. Every preset title is now checked against what TMDB serves
for its id.
- Checking whether push is subscribed no longer awaits
`navigator.serviceWorker.ready`, which never settles when no worker is
registered - the check hung on any page load before registration.
- `getErrorMessage` returns its fallback for a server error carrying an empty
message array, rather than `undefined`.
### Removed
- The dead `src/api/index.ts` barrel and the fourteen API functions it alone
referenced, three orphaned types, the Vite scaffold SVGs, the unused
`scroll-area` primitive and four unused dialog/sheet exports, and the
`_onGameStart` escape hatch that two stores read but nothing ever set.
Nothing a user could reach.
## [2.1.0] - 2026-09-11
Accessibility (WP-16: audit section 2.8).
### Added
- `eslint-plugin-jsx-a11y` (recommended rules) in the lint step, so an orphaned
label, a clickable `<div>` or a missing ARIA attribute fails CI instead of
shipping. It found 19 violations on arrival; all are fixed, none suppressed.
- A "Skip to content" link on every page, visually hidden until focused, that
moves focus to the main region.
- Live regions where state used to change silently: link validation announces
"Validating", a rejection (as an alert) and "Connected"; the versus and
game-night lobby countdowns are assertive timers, so a screen-reader user is
no longer dropped into a running match with no warning; the game-night
player list announces players finishing or forfeiting.
- Tests for the notification popover's keyboard contract (9 tests).
### Changed
- **The notification bell is a real popover.** It was a `<div>` with
`<div onClick>` rows: nothing in it was focusable, nothing announced, no way
to close it from the keyboard. The trigger now reports its state and the
unread count in its name (`Notifications, 3 unread`), the panel is a labelled
dialog the trigger controls, every notification is a button, the per-row
controls have real names rather than tooltips, Escape closes and returns
focus to the trigger, and the unread dot has text behind it.
- **The Challenge Friend overlay is the UI kit's `Dialog`.** It was a fixed
`<div>` with no role, no focus trap and no Escape - Tab went straight through
it into the page behind. Base UI supplies the dialog role, `aria-modal`,
focus trapping, Escape and focus restoration.
- The search combobox follows the ARIA 1.2 pattern: `role="combobox"` with
`aria-expanded`/`aria-controls` on the input itself, rather than the 1.1
wrapper-`div`-with-`aria-owns` form that current screen readers announce
inconsistently. Verified live: the state flips and `aria-controls` resolves
to the listbox.
- Every form control has a label. Sign-in/sign-up, the three change-password
fields (which had only placeholders, and placeholders vanish once you type),
the admin challenge form, custom game, game night's room form (including a
join-password field that had nothing at all), the versus lobby form, the
friend search and the chat message box. Labels above groups of buttons
("Difficulty", "Time Limit", "Movie A") are group labels via
`role="group"`/`aria-labelledby` rather than a faked `htmlFor`.
- Every icon-only button has a name that says what it does: chat send, back,
pagination, display-name save/cancel, notification actions, and three versus
buttons whose content collapsed to a bare spinner while loading.
- The game header's "Passed Movie B" read the same text in both states - only
the icon and colour changed - so a screen reader heard the wrong thing until
B was reached. It now announces "Movie B reached" or "not yet reached", and
the online indicator on the friends list has text behind the green dot.
- `autoFocus` is gone from the friend search. It skipped screen-reader users
past the page heading; the field is one Tab away.
- **Reduced motion is respected globally.** A single stylesheet rule collapses
every animation and transition when the OS preference is set; the confetti
overlay does not render at all under it, since a still frame of confetti has
no meaning.
## [2.0.1] - 2026-09-11
Bundle and asset budget (WP-15: M26).
### Added
- A test suite, where there was none: vitest with `@testing-library/react` and
jsdom, run by `npm test`, with coverage via `npm run test:cov`. The first
tests are the regression net for the audit's C1 - every multiplayer mode
used to leave the completion modal on "Calculating score..." forever because
no backend session was ever created - and for the chain-validation state
machine in both its server-validated and anonymous client-side forms. 51
tests. Coverage thresholds of 90% (lines, branches, functions, statements)
are codified in `vite.config.ts`; the suite does not meet them yet, so
`test:cov` fails honestly while CI runs the plain `npm test`. Tests intercept
the axios client rather than the API modules, because a second concurrent
dynamic `import()` of a mocked aliased module resolved to the real file under
vitest - measured, and it reached the development backend once.
- A `test` job in CI.
### Changed
- **Every page except Home loads on demand.** `App.tsx` imported all 24 pages
statically, so a first-time visitor downloaded the admin panel, three
multiplayer modes and the preset-pair table before the landing page could
render: one 682 KB chunk. Routes are now `React.lazy` behind a `Suspense`
boundary, giving 56 chunks with pages at 2-11 KB each. Verified in a browser
against the production build: the landing page fetches only the app chunk and
vendor chunks; navigating to a route fetches that route's chunks and renders;
a deep link into an authenticated route lazy-loads it, redirects to sign-in,
and lazy-loads that too.
- **Vendor code is split into separately cached chunks** (`vendor-react`,
`vendor-router`, `vendor-ui`, `vendor-net`) via `manualChunks`. The first
load is not much smaller - the main chunk was 95% dependencies, and the
dependency set is the load - but the chunk that changes on every deploy is now
**50 KB (14 KB gzipped)** rather than 682 KB, so a returning visitor after a
deploy re-downloads the app, not React. Vite's 500 KB chunk warning no longer
fires.
### Fixed
- The favicon was an 888 KB image - PNG data under a `.jpg` name, 768x768 with
a fully opaque alpha channel - fetched on every page load. It is now a 2.6 KB
32px PNG, with a 180px `apple-touch-icon` for iOS home screens.
- `manifest.json` declared that same file as both its 192x192 and 512x512 icon,
typed `image/jpeg`. It now points at real 192px and 512px PNGs with the
correct type, so installs get a correctly sized icon instead of a mislabelled
one the browser has to rescale.
- The service worker's push notifications referenced `/favicon.ico`, which never
existed; a push with no icon in its payload rendered blank. They use the 192px
icon.
### Known issues
- The `useGameStore()` selector item from M26 was already resolved in WP-10;
the chain-validation hook subscribes per field.
- Not done, recorded for later: `socket.io-client` (~90 KB of `vendor-net`)
ships to anonymous visitors because the page chrome's notification bell
reaches the notification store, which imports it statically. Loading the
socket client on demand would mean an async `connect()` in four stores - a
change in its own right, not a bundle setting.
## [2.0.0] - 2026-09-10
Container and response-header hardening (WP-13: M31, M34).
### ⚠ Breaking
- **The production container now listens on 8080, not 80.** nginx runs as an
unprivileged user, and a non-root process cannot bind a privileged port without
`CAP_NET_BIND_SERVICE`, which compose drops. Anything routing to this container
must be repointed: in the homelab Traefik config that means
`traefik.http.services.movieloop.loadbalancer.server.port` changes from `80` to
`8080`. The published mapping in `docker-compose.prod.yml` is updated here, but
the Traefik label lives outside this repo and must be changed by hand.
### Fixed
- Security headers are no longer dropped from most responses. nginx's
`add_header` is not additive across levels: a block declaring any `add_header`
of its own discards every header inherited from its parent. `location /assets/`
and `location = /config.js` each declared one, so `X-Frame-Options`,
`X-Content-Type-Options` and `Referrer-Policy` were silently absent from every
JavaScript and CSS response - which is most of what a browser loads. The
headers now come from a snippet included in each location. Verified against the
built image: 6 of 6 headers present on `/`, on a hashed `/assets/*.js` bundle,
and on `/config.js`.
- `config.js` no longer interpolates `API_URL` unescaped. This used `envsubst`,
which does no escaping, so a value containing a double quote closed the
JavaScript string literal and everything after it became executable script on
every page load. The value is now escaped when rendered. Operator-supplied, so
not a likely attack - but the page hands its auth token to whatever origin that
value resolves to, and a malformed value is now inert rather than executable.
### Added
- A Content-Security-Policy, plus `Strict-Transport-Security` and
`Permissions-Policy`. With the JWT in `localStorage`, CSP is the main
compensating control against injected script and there was none. `connect-src`
is derived at container start from `API_URL`, because the API is not
same-origin in production - the app is served from one subdomain and the API
from another, so naming it explicitly is required or every request and
websocket would be blocked. The inline theme script in `index.html` runs before
first paint to avoid a flash of the wrong theme and so cannot move to a file;
it is allowed by SHA-256 hash rather than by `unsafe-inline`, which would have
bought almost nothing. **If that script is edited its hash must be regenerated
in `docker/40-render-config.sh` or the page will not apply its theme.**
### Changed
- The production image is based on `nginxinc/nginx-unprivileged:alpine` and runs
as uid 101 rather than root. It serves static files and never needed root.
Verified in the built image: `uid=101(nginx)`.
- `envsubst` is no longer installed, since the entrypoint renders `config.js`
itself in order to escape the value.
## [1.3.0] - 2026-09-10
Session handling and effect correctness.
### Fixed
- An expired or revoked session no longer leaves the app half signed in. The
response interceptor only wrote to the console, so the nav still showed your
name while every request quietly failed. A 401 now clears the token and
returns you to sign-in. Signing in with the wrong password is unaffected -
that is an answer, not an expired session.
- Signing out ends the whole session. It disconnected the notifications socket
and nothing else, leaving the chat, versus and game night sockets open with a
token that had just been removed, and the friends and game stores populated -
so signing in as someone else on the same tab showed the previous person's
friend list and pending-request badge.
- Leaving a match no longer carries its result into the next one. Disconnecting
cleared only the socket, so the match id, result and score survived and the
next match opened showing the previous match's completion modal.
- Reconnecting no longer duplicates everything. All four sockets tested whether
the existing connection was *connected* rather than whether it existed, so one
that was mid-reconnect got replaced without being closed; the abandoned socket
kept its own handlers and delivered every event a second time.
- Game night spectators are detected again. The check decoded the session token
with `atob`, which rejects the base64url encoding tokens actually use, so it
threw inside a socket handler and the spectator flag was never set.
- Reloading your profile no longer discards a game in progress. Effects depended
on the whole user object, which is rebuilt on every profile load, so they
re-ran when nothing had changed - and in an async match the re-run reset the
board mid-game.
- Undo is applied in order and no longer drifts from the server. Rapid undos
fired overlapping requests at a server that removes one link per call, and a
failure was only logged while the local chain had already been shortened.
- Leaving a page cancels its in-flight requests instead of letting them land
afterwards.
- The friends page no longer removes other components' socket listeners when it
unmounts.
- Copying a result no longer leaves a timer running after the page closes.
- Share links point at the deployment that produced them, rather than always at
production.
### Security
- The runtime API setting is escaped when written into the page. It was
substituted raw, so a value containing a quote ended the string and the rest
became executable JavaScript on every page load - and the page hands its auth
token to whatever that value resolves to. A malformed value is now also
rejected on read, falling back to the default.
### Removed
- The difficulty selector in Endless mode. It was never applied to anything:
difficulty is derived from cast analysis on the server, and Endless builds its
pairs in the browser, so the control silently did nothing. The genre and
decade filters are unaffected.
## [1.2.3] - 2026-09-10
### Fixed
- The async challenge leaderboard no longer shows an empty heading to anyone
who has not played that match. The backend now withholds the movie pair from
non-participants - the pair is the puzzle - so the two titles arrive absent
rather than empty, and the page rendered a bare separator between nothing.
It now says the movies are hidden until you play, and the type reflects that
they are optional.
## [1.2.2] - 2026-09-09
### Fixed
+23 -7
View File
@@ -21,14 +21,30 @@ COPY . .
RUN npm run build
# --- Production stage ---
FROM nginx:alpine AS production
# nginx-unprivileged rather than nginx: the stock image runs its master process
# as root, which this container has no need for - it serves static files. It
# listens on 8080 because a non-root process cannot bind a privileged port
# without CAP_NET_BIND_SERVICE, and that capability is dropped in compose.
FROM nginxinc/nginx-unprivileged:alpine AS production
# Package upgrades and directory ownership need root; the image drops back to
# its unprivileged user before nginx starts.
USER root
# Pull current security fixes for OS packages inherited from the base image
# (e.g. nghttp2-libs CVE-2026-27135), then install envsubst for runtime config.
RUN apk upgrade --no-cache && apk add --no-cache gettext
# (e.g. nghttp2-libs CVE-2026-27135). envsubst is no longer installed:
# 40-render-config.sh writes config.js itself so it can escape the value, which
# envsubst cannot do.
RUN apk upgrade --no-cache
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/config.js.template /etc/nginx/templates/config.js.template
COPY docker/40-render-config.sh /docker-entrypoint.d/40-render-config.sh
RUN chmod +x /docker-entrypoint.d/40-render-config.sh
EXPOSE 80
# nginx:alpine's upstream entrypoint runs /docker-entrypoint.d/*.sh then launches nginx.
# The entrypoint script writes config.js and the security-header snippet at
# start-up, so both destinations must be writable by the unprivileged user.
# 101 is the nginx uid/gid in this image.
RUN chmod +x /docker-entrypoint.d/40-render-config.sh && mkdir -p /etc/nginx/snippets && chown -R 101:101 /usr/share/nginx/html /etc/nginx/snippets
USER 101
EXPOSE 8080
# The upstream entrypoint runs /docker-entrypoint.d/*.sh then launches nginx.
+109 -58
View File
@@ -1,73 +1,124 @@
# React + TypeScript + Vite
# movieloop-frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
The web client for **"You Know Who Else Was In That Movie?"** — a trivia-puzzle game in the spirit of Six Degrees of Kevin Bacon. You are given two movies and build a chain of actor→movie links that loops back to where you started. Every link is checked against [TMDB](https://www.themoviedb.org/) as you type; the server scores the result.
Currently, two official plugins are available:
React 19 · TypeScript (strict) · Vite 7 · Tailwind CSS v4 + shadcn/Base UI · React Router 7 · Zustand · Axios · socket.io-client.
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
The API lives in a sibling repository, `movieloop-backend`. The two are orchestrated by the compose files in the parent folder, which is deliberately not itself a git repository.
## React Compiler
## What's in the app
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
| Route | What it is |
|---|---|
| `/`, `/game` | Quick play — a random pair, chain-building, scoring |
| `/daily` | The three daily challenges (easy / medium / hard), same puzzles for everyone, ranked on the daily leaderboard |
| `/endless`, `/custom` | Endless mode with genre/decade filters; pick-your-own-pair |
| `/versus`, `/versus/lobby/:matchId`, `/versus/game` | Live head-to-head with a shared puzzle and opponent progress |
| `/versus/async-game/:matchId`, `/versus/async/:matchId/leaderboard` | Challenge a friend; they play the same pair later |
| `/game-night`, `/game-night/lobby/:roomId`, `/game-night/play`, `/game-night/:roomId/results` | Host a room for a table of players |
| `/leaderboard`, `/achievements`, `/profile`, `/game/:id/review` | Ranks, badges, account settings, replay a finished chain |
| `/friends`, `/friends/add`, `/friends/chat/:friendId` | Friends, requests, direct messages |
| `/admin` | Admin console (admins only) |
## Expanding the ESLint configuration
Every route is code-split with `React.lazy`; vendor libraries are chunked separately so a deploy invalidates only the app chunk.
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
## Running it
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
### With Docker (the normal way)
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
From the parent folder:
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```sh
docker compose up -d # postgres, redis, rabbitmq, backend, and this app on http://localhost:5173
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
The dev container runs Vite with file polling on (`VITE_USE_POLLING`), because a Docker Desktop bind mount on Windows delivers no file-change events.
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
### Natively
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```sh
npm ci
npm run dev # http://localhost:5173, proxies /api and /socket.io to localhost:3000
```
The backend must be running on port 3000 (see its README). No `.env` is needed for development.
### Configuration
The built app reads its API base at runtime, not at build time, so one image serves every environment.
| Where | Name | Meaning |
|---|---|---|
| Container env | `API_URL` | API base, e.g. `https://api.example.com/api/v1` or a same-origin path. Rendered into `/config.js` and into the CSP's `connect-src` when the container starts. Defaults to `/api/v1`. |
| Build-time (dev only) | `VITE_API_URL` | Fallback used by `npm run dev` / `npm run build` when no `/config.js` is served. |
| Dev container | `VITE_USE_POLLING` | Turns on Vite's polling watcher. |
`src/lib/config.ts` rejects a malformed `API_URL` (anything that is not an http(s) URL or a leading-slash path) and falls back, loudly, because the socket layer sends the auth token to whatever this resolves to.
## Development
```sh
npm run lint # eslint --max-warnings 0, including jsx-a11y
npx tsc -b
npm test # vitest + testing-library, jsdom
npm run test:cov # what CI runs: fails below 90% lines/branches/functions/statements
npm run build # tsc -b && vite build -> dist/
npm run preview # serve dist/ locally
```
Coverage thresholds live in `vite.config.ts`; every source file is above them individually. The suite mocks the axios client and fails loudly on any request it did not route, and mocks the socket factory with a controllable fake, so no test can reach a real backend. Pages are rendered with real Zustand stores seeded via `setState`, so the stores' selectors and actions are covered by the page tests as well as their own. One toolchain note: `vi.useFakeTimers()` combined with `@testing-library/user-event` deadlocks under this vitest/jsdom combination — tests that need both use real timers and `waitFor`.
`noUncheckedIndexedAccess` is on: indexing an array or record gives you `T | undefined`, and the code handles it rather than asserting it away. There are no `any`, no `eslint-disable` and no `@ts-ignore` in `src/`; the lint gate is configured to keep it that way.
### Layout
```
src/
├── api/ one module per backend resource, all through api/client.ts (axios)
├── components/
│ ├── game/ ChainDisplay, SearchAutocomplete (ARIA combobox), GameHeader, completion modals…
│ ├── game-night/, versus/
│ ├── layout/ PageLayout, nav, NotificationBell
│ ├── profile/ PushNotificationsSection
│ └── ui/ shadcn primitives (Base UI)
├── hooks/ use-game-screen (the shared game-page shell), use-chain-validation, use-timer, use-hints, use-push-subscription
├── lib/ config, error, format, movie-pair(s), push, sounds, create-socket, storage
├── pages/ one file per route
├── stores/ Zustand: auth, game, versus, game-night, chat, friends, notifications
├── types/
└── App.tsx routes (lazy) + Suspense
public/
├── sw.js service worker: push display + notification click
├── manifest.json, icon-192.png, icon-512.png, apple-touch-icon.png, favicon-32.png
└── sounds/
```
### How a game works, client side
`game-store` owns the session: it creates one on the server, sends each link to `POST /games/:id/validate-link`, and asks the server to score on completion. The store never computes a score. When the backend cannot be reached, anonymous play falls back to checking TMDB credits client-side through the API proxy — but such a game has no session and is not scored or ranked.
Multiplayer pages share `use-game-screen`, which wires the store, the timer, hints and the search panel; the mode-specific pages add the socket handling.
### Sockets
Four namespaces (`/notifications`, `/chat`, `/versus`, `/game-night`), created by `lib/create-socket.ts` with the JWT in `auth.token`. Each store owns exactly one socket, and `connect()` replaces (and disconnects) a stale instance rather than stacking a second one. Signing out disconnects all four and resets every store that holds another user's data.
### Push notifications
Opt-in from **Profile → Notifications**. The service worker is registered at sign-in; enabling push asks the browser for permission and registers the subscription with the server. The server sends push only when its VAPID keys are configured — otherwise the toggle explains that push is not available here.
### Accessibility
`eslint-plugin-jsx-a11y` runs at error level. Every form control has a label, every icon-only button has a name, dialogs are real dialogs (Base UI), validation results and countdowns are announced through live regions, there is a skip link, and `prefers-reduced-motion` collapses every animation.
## Production image
`Dockerfile` has three stages: `dev` (Vite), `build`, and `production` — `nginxinc/nginx-unprivileged` serving `dist/` on **port 8080** as uid 101. `docker/40-render-config.sh` runs at container start to write `/config.js` and the security-header snippet (CSP with the API origin in `connect-src`, HSTS, frame and MIME protections) from `API_URL`. `nginx.conf` repeats the headers in every `location`, since nginx's `add_header` is not inherited.
## Releasing
Versions follow semver and every behavioural change updates `CHANGELOG.md` in the same commit. On a push to `main`, CI lints, typechecks, tests, scans (gitleaks, semgrep, Trivy fs + image), builds the image, refuses to proceed if `v<version>` already exists as a git tag or as a Harbor image tag, pushes to Harbor, signs with cosign, and pushes the git tag back. Bump `package.json` before merging or the run fails on purpose.
## License
Private. All rights reserved.
Executable → Regular
+69 -5
View File
@@ -1,9 +1,73 @@
#!/bin/sh
# Render /config.js from the env var API_URL at container start.
# Runs as part of the official nginx:alpine /docker-entrypoint.d sequence,
# so nginx itself is launched by the upstream entrypoint after this script.
# Renders the two things that depend on runtime configuration, before nginx
# starts: /config.js (the API base the app reads) and the security-header
# snippet (whose CSP has to name that same API origin).
#
# Runs as part of the official nginx:alpine /docker-entrypoint.d sequence, so
# nginx itself is launched by the upstream entrypoint after this script.
set -e
: "${API_URL:=/api/v1}"
export API_URL
envsubst < /etc/nginx/templates/config.js.template > /usr/share/nginx/html/config.js
# --------------------------------------------------------------- config.js
# The value is interpolated into a JavaScript string literal, so it is escaped
# rather than substituted raw. This previously used envsubst, which does no
# escaping at all: an API_URL containing a double quote closed the literal and
# everything after it became executable JavaScript on every page load. Operator
# supplied, so not a likely attack - but it is one typo away from a baffling
# breakage, and the page then hands its auth token to whatever origin the
# value resolves to.
#
# One substitution prefixes every backslash and double quote with a backslash;
# doing both in a single pass avoids the ordering trap where escaping quotes
# first would then double-escape the backslashes just added. Newlines are
# dropped afterwards since neither can appear in a JS string literal.
escaped=$(printf '%s' "$API_URL" | sed 's/[\\"]/\\&/g' | tr -d '\n\r')
printf 'window.__APP_CONFIG__ = { apiUrl: "%s" };\n' "$escaped" \
> /usr/share/nginx/html/config.js
# ------------------------------------------------------- security headers
# The API is not necessarily same-origin: in production the app is served from
# one subdomain and the API from another, so connect-src has to name it
# explicitly or every request and every websocket would be blocked.
#
# A relative API_URL means same-origin, and 'self' already covers it.
api_origin=""
case "$API_URL" in
http://*|https://*)
# scheme://host[:port] - everything up to the third slash.
api_origin=$(printf '%s' "$API_URL" | cut -d/ -f1-3)
;;
esac
# Websockets need their own entry: ws(s): is a distinct scheme to CSP even when
# the host is identical.
ws_origin=""
if [ -n "$api_origin" ]; then
ws_origin=$(printf '%s' "$api_origin" | sed -e 's/^http:/ws:/' -e 's/^https:/wss:/')
fi
connect_src="'self' ${api_origin} ${ws_origin}"
# The inline theme script in index.html runs before first paint to avoid a
# flash of the wrong theme, so it cannot be moved to a file without either a
# blocking request or the flash coming back. Allowing it by hash keeps
# 'unsafe-inline' out of the policy - with 'unsafe-inline' a CSP buys almost
# nothing against injected script, which is the main thing it is here to stop.
#
# If that script is edited, this hash must be regenerated or the page will not
# apply its theme. It is asserted by the header test in the repo.
theme_script_hash="sha256-8evBcs4UKW13abYRRwTcDjBEK5h9IWhKnpEbZ+Mv4nQ="
mkdir -p /etc/nginx/snippets
cat > /etc/nginx/snippets/security-headers.conf <<EOF
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; object-src 'none'; script-src 'self' '${theme_script_hash}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:; connect-src ${connect_src}" always;
EOF
echo "[config] API_URL=${API_URL} -> /config.js"
echo "[config] CSP connect-src: ${connect_src}"
-1
View File
@@ -1 +0,0 @@
window.__APP_CONFIG__ = { apiUrl: "${API_URL}" };
+6
View File
@@ -2,6 +2,7 @@ import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
@@ -14,6 +15,11 @@ export default defineConfig([
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
// Accessibility at lint time. Before this, an orphaned <label>, an
// icon-only button with no name, or a clickable <div> with no role
// passed CI - which is how the whole login form, three password fields
// and a hand-rolled dialog ended up unreachable to a screen reader.
jsxA11y.flatConfigs.recommended,
],
languageOptions: {
// Matches the ES2022 target in tsconfig.app.json.
+2 -1
View File
@@ -2,7 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/icon.jpg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>You Know Who Else Was In That Movie?</title>
<meta name="description" content="A trivia-puzzle game inspired by Six Degrees of Kevin Bacon. Build a chain of actor-movie connections forming a complete loop between two movies." />
+18 -7
View File
@@ -1,14 +1,22 @@
server {
listen 80;
listen [::]:80;
# 8080, not 80: the server runs unprivileged and cannot bind a
# privileged port without a capability that compose drops.
listen 8080;
listen [::]:8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Security headers are pulled in from a snippet rendered at container start
# (docker/40-render-config.sh), because the CSP has to name the API origin
# and that is only known from API_URL at runtime.
#
# The include is repeated in every location that declares an add_header of
# its own. nginx's add_header is NOT additive across levels: a block that
# sets any add_header discards every header inherited from its parent. That
# is why these headers were silently missing from every JavaScript and CSS
# response, which is most of what a browser loads.
include /etc/nginx/snippets/security-headers.conf;
# Gzip compression
gzip on;
@@ -17,18 +25,21 @@ server {
# Cache static assets
location /assets/ {
include /etc/nginx/snippets/security-headers.conf;
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cache-Control "public, immutable" always;
}
# Runtime config — rendered fresh on each container start. Never cache.
location = /config.js {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "no-store" always;
try_files /config.js =404;
}
# SPA fallback — serve index.html for all non-file routes
location / {
include /etc/nginx/snippets/security-headers.conf;
try_files $uri $uri/ /index.html;
}
}
+2707 -5
View File
File diff suppressed because it is too large. Load diff
+14 -3
View File
@@ -1,7 +1,7 @@
{
"name": "frontend-temp",
"name": "movie-loop-frontend",
"private": true,
"version": "1.2.2",
"version": "2.2.2",
"type": "module",
"license": "MIT",
"author": "Kevin Riehl",
@@ -9,6 +9,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"preview": "vite preview",
"release": "commit-and-tag-version",
"release:minor": "commit-and-tag-version --release-as minor",
@@ -33,20 +36,28 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^5.0.0",
"commit-and-tag-version": "^12.5.0",
"eslint": "^9.39.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^30.0.1",
"shadcn": "^4.0.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^5.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 867 KiB

+4 -4
View File
@@ -7,14 +7,14 @@
"theme_color": "#1e1b30",
"icons": [
{
"src": "/icon.jpg",
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/jpeg"
"type": "image/png"
},
{
"src": "/icon.jpg",
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/jpeg"
"type": "image/png"
}
]
}
+2 -2
View File
@@ -6,8 +6,8 @@ self.addEventListener('push', (event) => {
const payload = event.data.json();
const options = {
body: payload.body,
icon: payload.icon || '/favicon.ico',
badge: '/favicon.ico',
icon: payload.icon || '/icon-192.png',
badge: '/icon-192.png',
data: { url: payload.url || '/' },
};
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+138
View File
@@ -0,0 +1,138 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* App.tsx: `loadUser` fires on mount, a lazy route shows `RouteFallback`
* while its chunk loads and then resolves to the real page, and an unknown
* path falls through to `NotFound`. A real `BrowserRouter` is awkward to
* drive directly, so the path is set via `window.history.pushState` before
* `<App />` renders, mirroring how the browser would have gotten there.
*
* Heavy lazy pages reach the network - intercepted one level below `@/api/*`,
* at `@/api/client`, per the project's convention (mocking `@/api/*` modules
* directly has previously resolved to the real module under a concurrent
* dynamic import and reached the dev backend). Home and NotFound need none of
* this: Home only fetches on a button click, and NotFound fetches nothing.
*/
function unrouted(method: string, url: string): never {
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string): Promise<{ data: unknown }> => {
if (method === 'get' && url === '/daily-challenges/today') {
return { data: [] };
}
return unrouted(method, url);
};
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: App } = await import('./App');
const { useAuthStore } = await import('@/stores/auth-store');
beforeEach(() => {
window.history.pushState({}, '', '/');
localStorage.clear();
useAuthStore.setState({ user: null, isLoading: false, error: null });
});
afterEach(() => {
window.history.pushState({}, '', '/');
});
describe('mount', () => {
it('loads the current user on mount', () => {
const loadUserSpy = vi
.spyOn(useAuthStore.getState(), 'loadUser')
.mockResolvedValue(undefined);
render(<App />);
expect(loadUserSpy).toHaveBeenCalledTimes(1);
});
});
describe('routing', () => {
it('shows the loading fallback while a lazy route\'s chunk is in flight, then the page itself', async () => {
window.history.pushState({}, '', '/daily');
render(<App />);
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
expect(
await screen.findByRole('heading', { name: 'Daily Challenges' }),
).toBeInTheDocument();
expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull();
});
it('resolves the eager home route directly, without a loading fallback', () => {
window.history.pushState({}, '', '/');
render(<App />);
expect(
screen.getByRole('heading', { name: 'You Know Who Else Was In That Movie?' }),
).toBeInTheDocument();
expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull();
});
it('falls through an unknown path to NotFound', async () => {
window.history.pushState({}, '', '/this-route-does-not-exist');
render(<App />);
expect(await screen.findByText('404')).toBeInTheDocument();
expect(screen.getByText('Page not found.')).toBeInTheDocument();
});
});
describe('every route resolves its lazy chunk', () => {
// Each of these pages has its own dedicated test file for behaviour; this
// sweep exists purely to prove App's routing table wires every path to a
// chunk that actually loads (each is its own `lazy(() => import(...))`
// call). Signed out throughout, so every auth-gated page below settles on
// its own redirect (most commonly to /login) rather than doing real work -
// and every one of them, redirected or not, ends up on a PageLayout-framed
// page, so the brand link is a reliable, page-agnostic settle signal.
const routes = [
'/game',
'/leaderboard',
'/profile',
'/login',
'/versus',
'/versus/lobby/m1',
'/versus/game',
'/versus/async-game/m1',
'/versus/async/m1/leaderboard',
'/achievements',
'/endless',
'/custom',
'/game-night',
'/game-night/lobby/r1',
'/game-night/play',
'/game-night/r1/results',
'/game/1/review',
'/friends',
'/friends/add',
'/friends/chat/f1',
'/admin',
];
it.each(routes)('resolves %s to a real page', async (path) => {
window.history.pushState({}, '', path);
render(<App />);
expect(await screen.findByRole('link', { name: 'Movie Loop' })).toBeInTheDocument();
});
});
+90 -53
View File
@@ -1,31 +1,54 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router'
import { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
import { Toaster } from 'sonner';
import { Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import Home from '@/pages/Home'
import Game from '@/pages/Game'
import DailyChallenge from '@/pages/DailyChallenge'
import Leaderboard from '@/pages/Leaderboard'
import Profile from '@/pages/Profile'
import Login from '@/pages/Login'
import Versus from '@/pages/Versus'
import VersusLobby from '@/pages/VersusLobby'
import VersusGame from '@/pages/VersusGame'
import AsyncVersusGame from '@/pages/AsyncVersusGame'
import AsyncMatchLeaderboard from '@/pages/AsyncMatchLeaderboard'
import Achievements from '@/pages/Achievements'
import Endless from '@/pages/Endless'
import CustomGame from '@/pages/CustomGame'
import GameNight from '@/pages/GameNight'
import GameNightLobby from '@/pages/GameNightLobby'
import GameNightPlay from '@/pages/GameNightPlay'
import GameNightResults from '@/pages/GameNightResults'
import GameReview from '@/pages/GameReview'
import Friends from '@/pages/Friends'
import AddFriend from '@/pages/AddFriend'
import Chat from '@/pages/Chat'
import Admin from '@/pages/Admin'
import NotFound from '@/pages/NotFound'
import Home from '@/pages/Home';
// Every page used to be imported statically, so a first-time visitor downloaded
// the admin panel, three multiplayer modes and the preset-pair table before the
// landing page could render - one 680 KB chunk. Home stays eager because it is
// where visitors land and it is light; everything else is fetched on the first
// navigation to it and cached by the browser from then on.
const Game = lazy(() => import('@/pages/Game'));
const DailyChallenge = lazy(() => import('@/pages/DailyChallenge'));
const Leaderboard = lazy(() => import('@/pages/Leaderboard'));
const Profile = lazy(() => import('@/pages/Profile'));
const Login = lazy(() => import('@/pages/Login'));
const Versus = lazy(() => import('@/pages/Versus'));
const VersusLobby = lazy(() => import('@/pages/VersusLobby'));
const VersusGame = lazy(() => import('@/pages/VersusGame'));
const AsyncVersusGame = lazy(() => import('@/pages/AsyncVersusGame'));
const AsyncMatchLeaderboard = lazy(
() => import('@/pages/AsyncMatchLeaderboard'),
);
const Achievements = lazy(() => import('@/pages/Achievements'));
const Endless = lazy(() => import('@/pages/Endless'));
const CustomGame = lazy(() => import('@/pages/CustomGame'));
const GameNight = lazy(() => import('@/pages/GameNight'));
const GameNightLobby = lazy(() => import('@/pages/GameNightLobby'));
const GameNightPlay = lazy(() => import('@/pages/GameNightPlay'));
const GameNightResults = lazy(() => import('@/pages/GameNightResults'));
const GameReview = lazy(() => import('@/pages/GameReview'));
const Friends = lazy(() => import('@/pages/Friends'));
const AddFriend = lazy(() => import('@/pages/AddFriend'));
const Chat = lazy(() => import('@/pages/Chat'));
const Admin = lazy(() => import('@/pages/Admin'));
const NotFound = lazy(() => import('@/pages/NotFound'));
/** Shown while a route's chunk is in flight - once per route per session. */
function RouteFallback() {
return (
<div
className="flex min-h-screen items-center justify-center"
role="status"
aria-live="polite"
aria-label="Loading"
>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
export default function App() {
const loadUser = useAuthStore((s) => s.loadUser);
@@ -37,32 +60,46 @@ export default function App() {
return (
<BrowserRouter>
<Toaster position="top-right" richColors closeButton />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/game" element={<Game />} />
<Route path="/daily" element={<DailyChallenge />} />
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/login" element={<Login />} />
<Route path="/versus" element={<Versus />} />
<Route path="/versus/lobby/:matchId" element={<VersusLobby />} />
<Route path="/versus/game" element={<VersusGame />} />
<Route path="/versus/async-game/:matchId" element={<AsyncVersusGame />} />
<Route path="/versus/async/:matchId/leaderboard" element={<AsyncMatchLeaderboard />} />
<Route path="/achievements" element={<Achievements />} />
<Route path="/endless" element={<Endless />} />
<Route path="/custom" element={<CustomGame />} />
<Route path="/game-night" element={<GameNight />} />
<Route path="/game-night/lobby/:roomId" element={<GameNightLobby />} />
<Route path="/game-night/play" element={<GameNightPlay />} />
<Route path="/game-night/:roomId/results" element={<GameNightResults />} />
<Route path="/game/:id/review" element={<GameReview />} />
<Route path="/friends" element={<Friends />} />
<Route path="/friends/add" element={<AddFriend />} />
<Route path="/friends/chat/:friendId" element={<Chat />} />
<Route path="/admin" element={<Admin />} />
<Route path="*" element={<NotFound />} />
</Routes>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/game" element={<Game />} />
<Route path="/daily" element={<DailyChallenge />} />
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/login" element={<Login />} />
<Route path="/versus" element={<Versus />} />
<Route path="/versus/lobby/:matchId" element={<VersusLobby />} />
<Route path="/versus/game" element={<VersusGame />} />
<Route
path="/versus/async-game/:matchId"
element={<AsyncVersusGame />}
/>
<Route
path="/versus/async/:matchId/leaderboard"
element={<AsyncMatchLeaderboard />}
/>
<Route path="/achievements" element={<Achievements />} />
<Route path="/endless" element={<Endless />} />
<Route path="/custom" element={<CustomGame />} />
<Route path="/game-night" element={<GameNight />} />
<Route
path="/game-night/lobby/:roomId"
element={<GameNightLobby />}
/>
<Route path="/game-night/play" element={<GameNightPlay />} />
<Route
path="/game-night/:roomId/results"
element={<GameNightResults />}
/>
<Route path="/game/:id/review" element={<GameReview />} />
<Route path="/friends" element={<Friends />} />
<Route path="/friends/add" element={<AddFriend />} />
<Route path="/friends/chat/:friendId" element={<Chat />} />
<Route path="/admin" element={<Admin />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter>
)
);
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import type { Achievement } from './achievements';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { getUserAchievements } = await import('./achievements');
const achievement = (): Achievement => ({
id: 'a1',
key: 'first_loop',
name: 'First Loop',
description: 'Complete your first loop',
icon: 'trophy',
category: 'general',
threshold: 1,
progress: 1,
unlocked: true,
unlockedAt: '2024-01-01T00:00:00Z',
});
describe('getUserAchievements', () => {
it('GETs /achievements/me with the abort signal and returns the data', async () => {
const data = [achievement()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getUserAchievements(controller.signal);
expect(get).toHaveBeenCalledWith('/achievements/me', { signal: controller.signal });
expect(result).toBe(data);
});
it('works without a signal', async () => {
get.mockResolvedValue({ data: [] });
await getUserAchievements();
expect(get).toHaveBeenCalledWith('/achievements/me', { signal: undefined });
});
});
+6 -7
View File
@@ -13,12 +13,11 @@ export interface Achievement {
unlockedAt: string | null;
}
export async function getAllAchievements(): Promise<Achievement[]> {
const { data } = await apiClient.get<Achievement[]>('/achievements');
return data;
}
export async function getUserAchievements(): Promise<Achievement[]> {
const { data } = await apiClient.get<Achievement[]>('/achievements/me');
export async function getUserAchievements(
signal?: AbortSignal,
): Promise<Achievement[]> {
const { data } = await apiClient.get<Achievement[]>('/achievements/me', {
signal,
});
return data;
}
+213
View File
@@ -0,0 +1,213 @@
import { describe, expect, it, vi } from 'vitest';
import type {
AdminUsersResponse,
BackfillGameOwnersResult,
BackfillUsernamesResult,
GeneratedChallenge,
PlatformStats,
RecalculateResult,
RetroactiveAchievementsResult,
} from './admin';
const get = vi.fn();
const post = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
},
}));
const {
getPlatformStats,
listUsers,
recalculateScores,
backfillGameOwners,
generateChallenge,
generateAllChallenges,
retroactiveAchievements,
backfillUsernames,
} = await import('./admin');
describe('getPlatformStats', () => {
it('GETs /admin/stats with the abort signal', async () => {
const data: PlatformStats = {
totalUsers: 1,
totalGames: 2,
completedGames: 1,
completionRate: 0.5,
totalChallenges: 3,
totalMatches: 4,
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getPlatformStats(controller.signal);
expect(get).toHaveBeenCalledWith('/admin/stats', { signal: controller.signal });
expect(result).toBe(data);
});
});
describe('listUsers', () => {
it('GETs /admin/users with the default page/limit and the abort signal', async () => {
const data: AdminUsersResponse = { data: [], total: 0, page: 1, limit: 50, totalPages: 0 };
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await listUsers(undefined, undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/admin/users', {
params: { page: 1, limit: 50 },
signal: controller.signal,
});
expect(result).toBe(data);
});
it('forwards an explicit page and limit', async () => {
get.mockResolvedValue({ data: { data: [], total: 0, page: 2, limit: 10, totalPages: 0 } });
await listUsers(2, 10);
expect(get).toHaveBeenCalledWith('/admin/users', { params: { page: 2, limit: 10 }, signal: undefined });
});
});
describe('recalculateScores', () => {
it('POSTs /admin/recalculate-scores with no cursor param when omitted', async () => {
const data: RecalculateResult = {
hasMore: false,
nextCursor: null,
updated: 5,
skipped: 0,
total: 5,
};
post.mockResolvedValue({ data });
const result = await recalculateScores();
expect(post).toHaveBeenCalledWith('/admin/recalculate-scores', undefined, { params: undefined });
expect(result).toBe(data);
});
it('forwards the cursor as a query param', async () => {
post.mockResolvedValue({
data: { hasMore: true, nextCursor: 'c2', updated: 5, skipped: 0, total: 10 },
});
await recalculateScores('c1');
expect(post).toHaveBeenCalledWith('/admin/recalculate-scores', undefined, {
params: { cursor: 'c1' },
});
});
});
describe('backfillGameOwners', () => {
it('POSTs /admin/backfill-user-ids with no cursor param when omitted', async () => {
const data: BackfillGameOwnersResult = {
hasMore: false,
nextCursor: null,
backfilled: 1,
ambiguous: 0,
unmatched: 0,
total: 1,
};
post.mockResolvedValue({ data });
const result = await backfillGameOwners();
expect(post).toHaveBeenCalledWith('/admin/backfill-user-ids', undefined, { params: undefined });
expect(result).toBe(data);
});
it('forwards the cursor as a query param', async () => {
post.mockResolvedValue({
data: { hasMore: false, nextCursor: null, backfilled: 1, ambiguous: 0, unmatched: 0, total: 1 },
});
await backfillGameOwners('c1');
expect(post).toHaveBeenCalledWith('/admin/backfill-user-ids', undefined, { params: { cursor: 'c1' } });
});
});
describe('generateChallenge', () => {
it('POSTs /admin/daily-challenges/generate with a default difficulty', async () => {
const data = { id: 'c1' };
post.mockResolvedValue({ data });
const result = await generateChallenge('2024-01-01');
expect(post).toHaveBeenCalledWith('/admin/daily-challenges/generate', {
date: '2024-01-01',
difficulty: 'medium',
});
expect(result).toBe(data);
});
it('forwards an explicit difficulty', async () => {
post.mockResolvedValue({ data: { id: 'c2' } });
await generateChallenge('2024-01-01', 'hard');
expect(post).toHaveBeenCalledWith('/admin/daily-challenges/generate', {
date: '2024-01-01',
difficulty: 'hard',
});
});
});
describe('generateAllChallenges', () => {
it('POSTs /admin/daily-challenges/generate-all with the date', async () => {
const data: GeneratedChallenge[] = [
{ id: 'c1', difficulty: 'easy', movieATitle: 'A', movieBTitle: 'B', par: 4 },
];
post.mockResolvedValue({ data });
const result = await generateAllChallenges('2024-01-01');
expect(post).toHaveBeenCalledWith('/admin/daily-challenges/generate-all', { date: '2024-01-01' });
expect(result).toBe(data);
});
});
describe('retroactiveAchievements', () => {
it('POSTs /admin/retroactive-achievements with no cursor param when omitted', async () => {
const data: RetroactiveAchievementsResult = {
hasMore: false,
nextCursor: null,
totalAwarded: 3,
usersAffected: 2,
};
post.mockResolvedValue({ data });
const result = await retroactiveAchievements();
expect(post).toHaveBeenCalledWith('/admin/retroactive-achievements', undefined, {
params: undefined,
});
expect(result).toBe(data);
});
it('forwards the cursor as a query param', async () => {
post.mockResolvedValue({
data: { hasMore: false, nextCursor: null, totalAwarded: 1, usersAffected: 1 },
});
await retroactiveAchievements('c1');
expect(post).toHaveBeenCalledWith('/admin/retroactive-achievements', undefined, {
params: { cursor: 'c1' },
});
});
});
describe('backfillUsernames', () => {
it('POSTs /admin/backfill-usernames and returns the count', async () => {
const data: BackfillUsernamesResult = { backfilled: 12 };
post.mockResolvedValue({ data });
const result = await backfillUsernames();
expect(post).toHaveBeenCalledWith('/admin/backfill-usernames');
expect(result).toBe(data);
});
});
+48 -13
View File
@@ -28,32 +28,68 @@ export interface AdminUsersResponse {
totalPages: number;
}
export interface RecalculateResult {
/**
* The maintenance jobs process one page per request. `hasMore` says whether
* to call again, and `nextCursor` is what to call again with.
*/
export interface BatchPage {
hasMore: boolean;
nextCursor: string | null;
}
export interface RecalculateResult extends BatchPage {
updated: number;
skipped: number;
leaderboardUpdated: number;
backfilled: number;
total: number;
}
export async function getPlatformStats(): Promise<PlatformStats> {
const { data } = await apiClient.get<PlatformStats>('/admin/stats');
export async function getPlatformStats(
signal?: AbortSignal,
): Promise<PlatformStats> {
const { data } = await apiClient.get<PlatformStats>('/admin/stats', {
signal,
});
return data;
}
export async function listUsers(
page = 1,
limit = 50,
signal?: AbortSignal,
): Promise<AdminUsersResponse> {
const { data } = await apiClient.get<AdminUsersResponse>('/admin/users', {
params: { page, limit },
signal,
});
return data;
}
export async function recalculateScores(): Promise<RecalculateResult> {
export async function recalculateScores(
cursor?: string,
): Promise<RecalculateResult> {
const { data } = await apiClient.post<RecalculateResult>(
'/admin/recalculate-scores',
undefined,
{ params: cursor ? { cursor } : undefined },
);
return data;
}
export interface BackfillGameOwnersResult extends BatchPage {
backfilled: number;
/** Left alone: more than one player matched, so no guess was made. */
ambiguous: number;
unmatched: number;
total: number;
}
export async function backfillGameOwners(
cursor?: string,
): Promise<BackfillGameOwnersResult> {
const { data } = await apiClient.post<BackfillGameOwnersResult>(
'/admin/backfill-user-ids',
undefined,
{ params: cursor ? { cursor } : undefined },
);
return data;
}
@@ -69,11 +105,6 @@ export async function generateChallenge(
return data;
}
export async function deleteChallenge(id: string) {
const { data } = await apiClient.delete(`/admin/daily-challenges/${id}`);
return data;
}
export interface GeneratedChallenge {
id: string;
difficulty: string;
@@ -92,14 +123,18 @@ export async function generateAllChallenges(
return data;
}
export interface RetroactiveAchievementsResult {
export interface RetroactiveAchievementsResult extends BatchPage {
totalAwarded: number;
usersAffected: number;
}
export async function retroactiveAchievements(): Promise<RetroactiveAchievementsResult> {
export async function retroactiveAchievements(
cursor?: string,
): Promise<RetroactiveAchievementsResult> {
const { data } = await apiClient.post<RetroactiveAchievementsResult>(
'/admin/retroactive-achievements',
undefined,
{ params: cursor ? { cursor } : undefined },
);
return data;
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import type { AuthResponse } from './auth';
const post = vi.fn();
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: {
post: (...a: unknown[]) => post(...a),
get: (...a: unknown[]) => get(...a),
},
}));
const { register, login, getProfile } = await import('./auth');
const authResponse = (): AuthResponse => ({
access_token: 'tok123',
user: { id: 'u1', email: 'a@b.com', username: 'a', isAdmin: false },
});
describe('register', () => {
it('POSTs /auth/register with credentials, defaulting rememberMe to false', async () => {
const data = authResponse();
post.mockResolvedValue({ data });
const result = await register('a@b.com', 'alice', 'pw123');
expect(post).toHaveBeenCalledWith('/auth/register', {
email: 'a@b.com',
username: 'alice',
password: 'pw123',
rememberMe: false,
});
expect(result).toBe(data);
});
it('forwards an explicit rememberMe', async () => {
post.mockResolvedValue({ data: authResponse() });
await register('a@b.com', 'alice', 'pw123', true);
expect(post).toHaveBeenCalledWith('/auth/register', {
email: 'a@b.com',
username: 'alice',
password: 'pw123',
rememberMe: true,
});
});
});
describe('login', () => {
it('POSTs /auth/login with credentials, defaulting rememberMe to false', async () => {
const data = authResponse();
post.mockResolvedValue({ data });
const result = await login('a@b.com', 'pw123');
expect(post).toHaveBeenCalledWith('/auth/login', {
email: 'a@b.com',
password: 'pw123',
rememberMe: false,
});
expect(result).toBe(data);
});
it('forwards an explicit rememberMe', async () => {
post.mockResolvedValue({ data: authResponse() });
await login('a@b.com', 'pw123', true);
expect(post).toHaveBeenCalledWith('/auth/login', {
email: 'a@b.com',
password: 'pw123',
rememberMe: true,
});
});
});
describe('getProfile', () => {
it('GETs /users/me and returns the data', async () => {
const data = { id: 'u1', username: 'alice' };
get.mockResolvedValue({ data });
const result = await getProfile();
expect(get).toHaveBeenCalledWith('/users/me');
expect(result).toBe(data);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import type { ChatMessage, ConversationEntry } from './chat';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { getConversations, getMessages } = await import('./chat');
const conversation = (): ConversationEntry => ({
friend: { id: 'friend-1', username: 'friend', displayName: null, avatarUrl: null },
lastMessageAt: '2024-01-01T00:00:00Z',
});
const messagesPage = (): { messages: ChatMessage[]; hasMore: boolean; nextCursor: string | null } => ({
messages: [
{
id: 'm1',
senderId: 'friend-1',
recipientId: 'me',
content: 'hi',
createdAt: '2024-01-01T00:00:00Z',
},
],
hasMore: false,
nextCursor: null,
});
describe('getConversations', () => {
it('GETs /chat/conversations and returns the data', async () => {
const data = [conversation()];
get.mockResolvedValue({ data });
const result = await getConversations();
expect(get).toHaveBeenCalledWith('/chat/conversations');
expect(result).toBe(data);
});
});
describe('getMessages', () => {
it('GETs /chat/:friendId/messages with a stringified default limit and no cursor', async () => {
const data = messagesPage();
get.mockResolvedValue({ data });
const result = await getMessages('friend-1');
expect(get).toHaveBeenCalledWith('/chat/friend-1/messages', { params: { limit: '50' } });
expect(result).toBe(data);
});
it('includes the cursor when paging backward', async () => {
get.mockResolvedValue({ data: messagesPage() });
await getMessages('friend-1', 'cursor-abc');
expect(get).toHaveBeenCalledWith('/chat/friend-1/messages', {
params: { limit: '50', cursor: 'cursor-abc' },
});
});
it('forwards a custom limit', async () => {
get.mockResolvedValue({ data: messagesPage() });
await getMessages('friend-1', undefined, 10);
expect(get).toHaveBeenCalledWith('/chat/friend-1/messages', { params: { limit: '10' } });
});
});
-5
View File
@@ -33,8 +33,3 @@ export async function getMessages(
const { data } = await apiClient.get(`/chat/${friendId}/messages`, { params });
return data;
}
export async function deleteMessage(messageId: string) {
const { data } = await apiClient.delete(`/chat/messages/${messageId}`);
return data;
}
+287
View File
@@ -0,0 +1,287 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosError, InternalAxiosRequestConfig } from 'axios';
/**
* The axios instance itself: the auth-header request interceptor, and the
* response interceptor's 401-driven session teardown - which must exclude
* `/auth/login` and `/auth/register` (a wrong password must surface on the
* form, not bounce the page), and must not fire at all for a browser that
* never had a session to begin with.
*
* `axios` is mocked so `.create()` returns a fake instance whose
* `interceptors.request.use` / `interceptors.response.use` calls are captured
* here and invoked directly against fake configs/errors - the same
* "invoke the captured handler" approach the WP task describes, since
* `client.ts` never exposes the interceptors any other way.
*/
const getAuthToken = vi.fn<() => string | null>();
const removeAuthToken = vi.fn();
vi.mock('@/lib/storage', () => ({
storage: {
getAuthToken: () => getAuthToken(),
removeAuthToken: () => removeAuthToken(),
},
}));
vi.mock('@/lib/config', () => ({
config: { apiUrl: '/api/v1' },
}));
type RequestHandler = (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig;
type ResponseSuccessHandler = (response: unknown) => unknown;
type ResponseErrorHandler = (error: AxiosError) => Promise<never>;
let requestHandler: RequestHandler = () => {
throw new Error('request interceptor was never captured');
};
let responseSuccessHandler: ResponseSuccessHandler = () => {
throw new Error('response success interceptor was never captured');
};
let responseErrorHandler: ResponseErrorHandler = () => {
throw new Error('response error interceptor was never captured');
};
const createMock = vi.fn();
vi.mock('axios', () => ({
default: {
create: (...args: unknown[]) => createMock(...args),
isCancel: (err: unknown) =>
typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ERR_CANCELED',
},
}));
createMock.mockImplementation(() => ({
interceptors: {
request: {
use: (fn: RequestHandler) => {
requestHandler = fn;
},
},
response: {
use: (success: ResponseSuccessHandler, error: ResponseErrorHandler) => {
responseSuccessHandler = success;
responseErrorHandler = error;
},
},
},
}));
const { default: apiClient } = await import('./client');
// `beforeEach` clears mock call history before each test runs, so the single
// `axios.create(...)` call made at module-evaluation time is captured here.
const createCallArgs: unknown = createMock.mock.calls[0]?.[0];
function stubLocation(pathname: string): { assign: ReturnType<typeof vi.fn> } {
const assign = vi.fn();
Object.defineProperty(window, 'location', {
value: { pathname, assign },
writable: true,
configurable: true,
});
return { assign };
}
const originalLocation = window.location;
function baseConfig(): InternalAxiosRequestConfig {
return { headers: {} } as InternalAxiosRequestConfig;
}
function axiosError(overrides: {
status?: number;
url?: string;
data?: { message?: string | string[] };
message?: string;
hasConfig?: boolean;
} = {}): AxiosError {
const { status, url, data, message, hasConfig = true } = overrides;
return {
message: message ?? 'Request failed',
config: hasConfig ? ({ url } as InternalAxiosRequestConfig) : undefined,
response:
status === undefined
? undefined
: ({ status, data: data ?? {} } as AxiosError['response']),
} as AxiosError;
}
beforeEach(() => {
vi.clearAllMocks();
getAuthToken.mockReturnValue(null);
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
configurable: true,
});
});
describe('instance configuration', () => {
it('is created with the configured base URL and standard headers', () => {
expect(createCallArgs).toEqual({
baseURL: '/api/v1',
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
});
});
it('exports the created instance', () => {
expect(apiClient).toBeDefined();
});
});
describe('request interceptor', () => {
it('attaches a bearer token when one is stored', () => {
getAuthToken.mockReturnValue('tok123');
const config = requestHandler(baseConfig());
expect(config.headers.Authorization).toBe('Bearer tok123');
});
it('leaves the headers untouched when there is no token', () => {
getAuthToken.mockReturnValue(null);
const config = requestHandler(baseConfig());
expect(config.headers.Authorization).toBeUndefined();
});
});
describe('response interceptor - success path', () => {
it('passes the response through unchanged', () => {
const response = { data: { ok: true } };
expect(responseSuccessHandler(response)).toBe(response);
});
});
describe('response interceptor - session expiry (401)', () => {
it('clears the token and redirects when a token existed and the endpoint is not an auth endpoint', async () => {
getAuthToken.mockReturnValue('tok123');
const { assign } = stubLocation('/game');
const error = axiosError({ status: 401, url: '/games/1' });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).toHaveBeenCalledTimes(1);
expect(assign).toHaveBeenCalledWith('/login?expired=1');
});
it('does not redirect again when already on the login page, but still clears the token', async () => {
getAuthToken.mockReturnValue('tok123');
const { assign } = stubLocation('/login');
const error = axiosError({ status: 401, url: '/games/1' });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).toHaveBeenCalledTimes(1);
expect(assign).not.toHaveBeenCalled();
});
it('treats a 401 with no config/url as a session expiry when a token exists', async () => {
getAuthToken.mockReturnValue('tok123');
const { assign } = stubLocation('/game');
const error = axiosError({ status: 401, hasConfig: false });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).toHaveBeenCalledTimes(1);
expect(assign).toHaveBeenCalledWith('/login?expired=1');
});
it('does not treat a 401 as a session expiry when there was never a token to lose', async () => {
getAuthToken.mockReturnValue(null);
const { assign } = stubLocation('/game');
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({ status: 401, url: '/games/1', data: { message: 'Unauthorized' } });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).not.toHaveBeenCalled();
expect(assign).not.toHaveBeenCalled();
expect(quiet).toHaveBeenCalledWith('[API Error]', 'Unauthorized');
quiet.mockRestore();
});
it('excludes /auth/login from session-expiry handling so a bad password surfaces on the form', async () => {
getAuthToken.mockReturnValue('tok123');
const { assign } = stubLocation('/login');
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({
status: 401,
url: '/auth/login',
data: { message: 'Invalid credentials' },
});
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).not.toHaveBeenCalled();
expect(assign).not.toHaveBeenCalled();
expect(quiet).toHaveBeenCalledWith('[API Error]', 'Invalid credentials');
quiet.mockRestore();
});
it('excludes /auth/register from session-expiry handling', async () => {
getAuthToken.mockReturnValue('tok123');
stubLocation('/register');
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({
status: 401,
url: '/auth/register',
data: { message: 'Email taken' },
});
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).not.toHaveBeenCalled();
quiet.mockRestore();
});
});
describe('response interceptor - non-401 / generic errors', () => {
it('logs the server-provided message and rejects with the original error', async () => {
stubLocation('/game');
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({ status: 500, data: { message: 'Internal error' } });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(quiet).toHaveBeenCalledWith('[API Error]', 'Internal error');
expect(removeAuthToken).not.toHaveBeenCalled();
quiet.mockRestore();
});
it('does not log an aborted request: the page cancelled it on purpose', async () => {
// Navigating away mid-fetch aborts the controller; axios rejects with a
// cancellation. Two of these per page change were showing up as errors.
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = { ...axiosError({ message: 'canceled' }), code: 'ERR_CANCELED' };
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(quiet).not.toHaveBeenCalled();
quiet.mockRestore();
});
it('falls back to the error message when there is no response', async () => {
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({ message: 'Network Error' });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(quiet).toHaveBeenCalledWith('[API Error]', 'Network Error');
quiet.mockRestore();
});
it('falls back to a generic message when neither a response nor a message exists', async () => {
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = { config: undefined, response: undefined, message: undefined } as unknown as AxiosError;
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(quiet).toHaveBeenCalledWith('[API Error]', 'An error occurred');
quiet.mockRestore();
});
});
+42
View File
@@ -19,9 +19,51 @@ apiClient.interceptors.request.use((config) => {
return config;
});
/**
* Requests where a 401 is an answer rather than an expired session.
*
* Signing in with the wrong password must surface the error on the form, not
* bounce the page.
*/
const AUTH_ENDPOINTS = ['/auth/login', '/auth/register'];
function isSessionExpiry(url: string | undefined, status: number): boolean {
if (status !== 401) return false;
if (!storage.getAuthToken()) return false; // never had a session to lose
return !AUTH_ENDPOINTS.some((path) => (url ?? '').includes(path));
}
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const status: number | undefined = error.response?.status;
/**
* An expired or revoked token left the app in a half-signed-in state: the
* user object stayed in memory, the nav still showed their name, and every
* request failed silently because this interceptor only logged to the
* console. Tokens last a day, or thirty with "keep me signed in", so this
* was reached in ordinary use.
*
* A full navigation rather than a router push: it drops all in-memory
* state and every open socket along with the page, which is exactly what
* should happen to a session that no longer exists - and it avoids this
* module having to reach into the stores, which would make the API client
* part of an import cycle with them.
*/
if (status && isSessionExpiry(error.config?.url, status)) {
storage.removeAuthToken();
if (window.location.pathname !== '/login') {
window.location.assign('/login?expired=1');
}
return Promise.reject(error);
}
// A request the page abandoned on purpose - navigating away mid-fetch
// aborts its controller - is not an error worth a console line; logging
// it as one buried the real ones.
if (axios.isCancel(error)) return Promise.reject(error);
const message =
error.response?.data?.message || error.message || 'An error occurred';
console.error('[API Error]', message);
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import type { DailyChallenge } from './daily-challenges';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { getTodaysChallenges } = await import('./daily-challenges');
const challenge = (): DailyChallenge => ({
id: 'c1',
date: '2024-01-01',
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
par: 5,
difficulty: 'medium',
});
describe('getTodaysChallenges', () => {
it('GETs /daily-challenges/today with the abort signal and returns the data', async () => {
const data = [challenge()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getTodaysChallenges(controller.signal);
expect(get).toHaveBeenCalledWith('/daily-challenges/today', { signal: controller.signal });
expect(result).toBe(data);
});
it('works without a signal', async () => {
get.mockResolvedValue({ data: [] });
await getTodaysChallenges();
expect(get).toHaveBeenCalledWith('/daily-challenges/today', { signal: undefined });
});
});
+4 -36
View File
@@ -13,44 +13,12 @@ export interface DailyChallenge {
difficulty: string;
}
export interface DailyChallengeHistory {
data: DailyChallenge[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export async function getTodaysChallenge(): Promise<DailyChallenge> {
const { data } = await apiClient.get<DailyChallenge>(
'/daily-challenges/today',
);
return data;
}
export async function getTodaysChallenges(): Promise<DailyChallenge[]> {
export async function getTodaysChallenges(
signal?: AbortSignal,
): Promise<DailyChallenge[]> {
const { data } = await apiClient.get<DailyChallenge[]>(
'/daily-challenges/today',
);
return data;
}
export async function getChallengeHistory(
page = 1,
limit = 20,
): Promise<DailyChallengeHistory> {
const { data } = await apiClient.get<DailyChallengeHistory>(
'/daily-challenges',
{ params: { page, limit } },
);
return data;
}
export async function getChallengeByDate(
date: string,
): Promise<DailyChallenge> {
const { data } = await apiClient.get<DailyChallenge>(
`/daily-challenges/${date}`,
{ signal },
);
return data;
}
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest';
import type { FriendEntry, PendingRequest, SearchResult, SentRequest } from './friends';
const get = vi.fn();
const post = vi.fn();
const del = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
delete: (...a: unknown[]) => del(...a),
},
}));
const {
getFriends,
getPendingRequests,
getSentRequests,
getPendingCount,
searchUsers,
sendFriendRequest,
acceptRequest,
rejectRequest,
removeFriend,
createFriendChallenge,
} = await import('./friends');
const friendProfile = () => ({ id: 'u1', username: 'alice', displayName: null, avatarUrl: null });
describe('getFriends', () => {
it('GETs /friends and returns the data', async () => {
const data: FriendEntry[] = [{ friendshipId: 'f1', friend: friendProfile(), since: '2024-01-01' }];
get.mockResolvedValue({ data });
const result = await getFriends();
expect(get).toHaveBeenCalledWith('/friends');
expect(result).toBe(data);
});
});
describe('getPendingRequests', () => {
it('GETs /friends/requests/pending and returns the data', async () => {
const data: PendingRequest[] = [
{ friendshipId: 'p1', from: friendProfile(), sentAt: '2024-01-01' },
];
get.mockResolvedValue({ data });
const result = await getPendingRequests();
expect(get).toHaveBeenCalledWith('/friends/requests/pending');
expect(result).toBe(data);
});
});
describe('getSentRequests', () => {
it('GETs /friends/requests/sent and returns the data', async () => {
const data: SentRequest[] = [{ friendshipId: 's1', to: friendProfile(), sentAt: '2024-01-01' }];
get.mockResolvedValue({ data });
const result = await getSentRequests();
expect(get).toHaveBeenCalledWith('/friends/requests/sent');
expect(result).toBe(data);
});
});
describe('getPendingCount', () => {
it('GETs /friends/pending-count and returns the data', async () => {
get.mockResolvedValue({ data: 4 });
const result = await getPendingCount();
expect(get).toHaveBeenCalledWith('/friends/pending-count');
expect(result).toBe(4);
});
});
describe('searchUsers', () => {
it('GETs /friends/search with the query param', async () => {
const data: SearchResult[] = [
{ ...friendProfile(), friendshipStatus: null, friendshipId: null, friendshipDirection: null },
];
get.mockResolvedValue({ data });
const result = await searchUsers('alice');
expect(get).toHaveBeenCalledWith('/friends/search', { params: { q: 'alice' } });
expect(result).toBe(data);
});
});
describe('sendFriendRequest', () => {
it('POSTs /friends/request with the addressee id', async () => {
const data = { friendshipId: 'p1' };
post.mockResolvedValue({ data });
const result = await sendFriendRequest('u2');
expect(post).toHaveBeenCalledWith('/friends/request', { addresseeId: 'u2' });
expect(result).toBe(data);
});
});
describe('acceptRequest', () => {
it('POSTs /friends/:id/accept', async () => {
const data = { friendshipId: 'p1' };
post.mockResolvedValue({ data });
const result = await acceptRequest('p1');
expect(post).toHaveBeenCalledWith('/friends/p1/accept');
expect(result).toBe(data);
});
});
describe('rejectRequest', () => {
it('POSTs /friends/:id/reject', async () => {
const data = { friendshipId: 'p1' };
post.mockResolvedValue({ data });
const result = await rejectRequest('p1');
expect(post).toHaveBeenCalledWith('/friends/p1/reject');
expect(result).toBe(data);
});
});
describe('removeFriend', () => {
it('DELETEs /friends/:id', async () => {
const data = { success: true };
del.mockResolvedValue({ data });
const result = await removeFriend('f1');
expect(del).toHaveBeenCalledWith('/friends/f1');
expect(result).toBe(data);
});
});
describe('createFriendChallenge', () => {
it('POSTs /versus/friend-challenge with friendId, difficulty and isAsync', async () => {
const data = { id: 'match1' };
post.mockResolvedValue({ data });
const result = await createFriendChallenge('friend-1', 'hard', true);
expect(post).toHaveBeenCalledWith('/versus/friend-challenge', {
friendId: 'friend-1',
difficulty: 'hard',
isAsync: true,
});
expect(result).toBe(data);
});
});
-5
View File
@@ -76,11 +76,6 @@ export async function removeFriend(friendshipId: string) {
return data;
}
export async function blockUser(userId: string) {
const { data } = await apiClient.post('/friends/block', { userId });
return data;
}
export async function createFriendChallenge(
friendId: string,
difficulty: string,
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameNightResults, GameNightRoom } from './game-night';
const get = vi.fn();
const post = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
},
}));
const { createRoom, getOpenRooms, joinRoom, getRoomResults } = await import('./game-night');
const room = (): GameNightRoom => ({
id: 'r1',
hostId: 'u1',
hostPlaying: true,
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
roomName: null,
hasPassword: false,
status: 'open',
maxPlayers: 8,
timeoutMinutes: null,
createdAt: '2024-01-01',
startedAt: null,
finishedAt: null,
host: { id: 'u1', username: 'alice' },
players: [],
});
describe('createRoom', () => {
it('POSTs /game-night with the given body and returns the room', async () => {
const data = room();
post.mockResolvedValue({ data });
const body = { movieAId: 1, movieATitle: 'A', movieBId: 2, movieBTitle: 'B' };
const result = await createRoom(body);
expect(post).toHaveBeenCalledWith('/game-night', body);
expect(result).toBe(data);
});
});
describe('getOpenRooms', () => {
it('GETs /game-night/open with the abort signal', async () => {
const data = [room()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getOpenRooms(controller.signal);
expect(get).toHaveBeenCalledWith('/game-night/open', { signal: controller.signal });
expect(result).toBe(data);
});
});
describe('joinRoom', () => {
it('POSTs /game-night/:id/join with an optional password', async () => {
const data = room();
post.mockResolvedValue({ data });
const result = await joinRoom('r1', 'secret');
expect(post).toHaveBeenCalledWith('/game-night/r1/join', { password: 'secret' });
expect(result).toBe(data);
});
});
describe('getRoomResults', () => {
it('GETs /game-night/:id/results with the abort signal', async () => {
const data: GameNightResults = {
room: {
id: 'r1',
roomName: null,
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
startedAt: null,
finishedAt: null,
status: 'finished',
},
host: { id: 'u1', username: 'alice' },
rankings: { byScore: [], byTime: [], byLinks: [] },
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getRoomResults('r1', controller.signal);
expect(get).toHaveBeenCalledWith('/game-night/r1/results', { signal: controller.signal });
expect(result).toBe(data);
});
});
+8 -18
View File
@@ -73,13 +73,12 @@ export async function createRoom(body: {
return data;
}
export async function getOpenRooms(): Promise<GameNightRoom[]> {
const { data } = await apiClient.get<GameNightRoom[]>('/game-night/open');
return data;
}
export async function getMyRooms(): Promise<GameNightRoom[]> {
const { data } = await apiClient.get<GameNightRoom[]>('/game-night/me');
export async function getOpenRooms(
signal?: AbortSignal,
): Promise<GameNightRoom[]> {
const { data } = await apiClient.get<GameNightRoom[]>('/game-night/open', {
signal,
});
return data;
}
@@ -94,22 +93,13 @@ export async function joinRoom(
return data;
}
export async function leaveRoom(roomId: string): Promise<void> {
await apiClient.post(`/game-night/${roomId}/leave`);
}
export async function getRoom(roomId: string): Promise<GameNightRoom> {
const { data } = await apiClient.get<GameNightRoom>(
`/game-night/${roomId}`,
);
return data;
}
export async function getRoomResults(
roomId: string,
signal?: AbortSignal,
): Promise<GameNightResults> {
const { data } = await apiClient.get<GameNightResults>(
`/game-night/${roomId}/results`,
{ signal },
);
return data;
}
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it, vi } from 'vitest';
import type { ScoreBreakdown } from '@/types';
import type { GameHistoryEntry, GameHistoryResponse, HintResponse, ValidateLinkResponse } from './games';
const get = vi.fn();
const post = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
},
}));
const {
createGameSession,
completeGameSession,
useHint,
getGameHistory,
validateLink,
undoLink,
getGameSession,
} = await import('./games');
const score = (): ScoreBreakdown => ({
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 300,
totalScore: 2400,
});
describe('createGameSession', () => {
it('POSTs /games with the required fields only when optional ones are omitted', async () => {
const data = { id: 'g1', startedAt: 'now' };
post.mockResolvedValue({ data });
const result = await createGameSession(100, 'Movie A', 200, 'Movie B');
expect(post).toHaveBeenCalledWith('/games', {
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
});
expect(result).toBe(data);
});
it('includes dailyChallengeId and release dates only when provided', async () => {
post.mockResolvedValue({ data: { id: 'g2', startedAt: 'now' } });
await createGameSession(100, 'Movie A', 200, 'Movie B', 'dc1', '2000-01-01', '2001-01-01');
expect(post).toHaveBeenCalledWith('/games', {
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
dailyChallengeId: 'dc1',
movieAReleaseDate: '2000-01-01',
movieBReleaseDate: '2001-01-01',
});
});
it('omits a null release date rather than sending null', async () => {
post.mockResolvedValue({ data: { id: 'g3', startedAt: 'now' } });
await createGameSession(100, 'Movie A', 200, 'Movie B', undefined, null, null);
const body = post.mock.calls[0]?.[1] as Record<string, unknown>;
expect(body).not.toHaveProperty('movieAReleaseDate');
expect(body).not.toHaveProperty('movieBReleaseDate');
expect(body).not.toHaveProperty('dailyChallengeId');
});
});
describe('completeGameSession', () => {
it('POSTs /games/:id/complete with an empty body and returns the score', async () => {
const data = score();
post.mockResolvedValue({ data });
const result = await completeGameSession('g1');
expect(post).toHaveBeenCalledWith('/games/g1/complete', {});
expect(result).toBe(data);
});
});
describe('useHint', () => {
it('POSTs /games/:id/hint with an empty body and returns the hint payload', async () => {
const data: HintResponse = { hintsUsed: 1, hintsRemaining: 2, actors: [] };
post.mockResolvedValue({ data });
const result = await useHint('g1');
expect(post).toHaveBeenCalledWith('/games/g1/hint', {});
expect(result).toBe(data);
});
});
describe('getGameHistory', () => {
it('GETs /games/me/history with default page/limit and the abort signal', async () => {
const data: GameHistoryResponse = {
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getGameHistory(undefined, undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/games/me/history', {
params: { page: 1, limit: 20 },
signal: controller.signal,
});
expect(result).toBe(data);
});
it('forwards an explicit page and limit', async () => {
get.mockResolvedValue({ data: { data: [], total: 0, page: 2, limit: 5, totalPages: 0 } });
await getGameHistory(2, 5);
expect(get).toHaveBeenCalledWith('/games/me/history', {
params: { page: 2, limit: 5 },
signal: undefined,
});
});
});
describe('validateLink', () => {
it('POSTs /games/:id/validate-link with the link and returns the result', async () => {
const data: ValidateLinkResponse = { valid: true, chain: [], loopComplete: false, passedMovieB: false };
post.mockResolvedValue({ data });
const link = { type: 'actor' as const, id: 7, name: 'Actor Seven' };
const result = await validateLink('g1', link);
expect(post).toHaveBeenCalledWith('/games/g1/validate-link', link);
expect(result).toBe(data);
});
});
describe('undoLink', () => {
it('POSTs /games/:id/undo and returns the resulting chain', async () => {
const data = { chain: [] };
post.mockResolvedValue({ data });
const result = await undoLink('g1');
expect(post).toHaveBeenCalledWith('/games/g1/undo');
expect(result).toBe(data);
});
});
describe('getGameSession', () => {
it('GETs /games/:id with the abort signal and returns the data', async () => {
const data = { id: 'g1' } as unknown as GameHistoryEntry;
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getGameSession('g1', controller.signal);
expect(get).toHaveBeenCalledWith('/games/g1', { signal: controller.signal });
expect(result).toBe(data);
});
});
+16 -5
View File
@@ -85,10 +85,11 @@ export interface GameHistoryResponse {
export async function getGameHistory(
page = 1,
limit = 20,
signal?: AbortSignal,
): Promise<GameHistoryResponse> {
const { data } = await apiClient.get<GameHistoryResponse>(
'/games/me/history',
{ params: { page, limit } },
{ params: { page, limit }, signal },
);
return data;
}
@@ -122,11 +123,21 @@ export async function validateLink(
return data;
}
export async function undoLink(gameId: string): Promise<void> {
await apiClient.post(`/games/${gameId}/undo`);
export async function undoLink(
gameId: string,
): Promise<{ chain: ChainLink[] }> {
const { data } = await apiClient.post<{ chain: ChainLink[] }>(
`/games/${gameId}/undo`,
);
return data;
}
export async function getGameSession(id: string): Promise<GameHistoryEntry> {
const { data } = await apiClient.get<GameHistoryEntry>(`/games/${id}`);
export async function getGameSession(
id: string,
signal?: AbortSignal,
): Promise<GameHistoryEntry> {
const { data } = await apiClient.get<GameHistoryEntry>(`/games/${id}`, {
signal,
});
return data;
}
-81
View File
@@ -1,81 +0,0 @@
export { searchMovies, getMovieDetails, getMovieCredits, discoverMovies } from './movies';
export {
searchPersons,
getPersonDetails,
getPersonMovieCredits,
} from './persons';
export { createGameSession, completeGameSession, getGameHistory, getGameSession } from './games';
export { register, login, getProfile } from './auth';
export {
getTodaysChallenge,
getTodaysChallenges,
getChallengeHistory,
getChallengeByDate,
} from './daily-challenges';
export {
getLeaderboard,
getUserStats,
getUserStreak,
checkDailyCompletion,
} from './leaderboards';
export {
getFriends,
getPendingRequests,
getSentRequests,
getPendingCount,
searchUsers,
sendFriendRequest,
acceptRequest,
rejectRequest,
removeFriend,
blockUser,
createFriendChallenge,
} from './friends';
export {
getNotifications,
getUnreadCount,
markNotificationRead,
markAllNotificationsRead,
deleteNotification,
deleteAllReadNotifications,
} from './notifications';
export {
getConversations,
getMessages,
deleteMessage,
} from './chat';
export {
createMatch,
getWaitingMatches,
joinMatch,
cancelMatch,
getMyWaitingMatches,
getMatch,
getUserMatchHistory,
submitCreatorScore,
startAsyncAttempt,
submitAsyncAttempt,
getAsyncLeaderboard,
} from './versus';
export {
createRoom,
getOpenRooms,
getMyRooms,
joinRoom,
leaveRoom,
getRoom,
getRoomResults,
} from './game-night';
export { updateProfile, changePassword } from './users';
export { getAllAchievements, getUserAchievements } from './achievements';
export { getVapidKey, subscribeToPush, unsubscribeFromPush } from './push';
export {
getPlatformStats,
listUsers,
recalculateScores,
generateChallenge,
deleteChallenge,
generateAllChallenges,
retroactiveAchievements,
backfillUsernames,
} from './admin';
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import type { DailyCompletionStatus, LeaderboardResponse, UserStats } from './leaderboards';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { getLeaderboard, getUserStats, checkDailyCompletion } = await import('./leaderboards');
const leaderboardResponse = (): LeaderboardResponse => ({
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
});
describe('getLeaderboard', () => {
it('GETs /leaderboards with the daily defaults and no difficulty filter', async () => {
const data = leaderboardResponse();
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getLeaderboard(undefined, undefined, undefined, undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/leaderboards', {
signal: controller.signal,
params: { period: 'daily', page: 1, limit: 20 },
});
expect(result).toBe(data);
});
it('forwards period, page, limit and difficulty', async () => {
get.mockResolvedValue({ data: leaderboardResponse() });
await getLeaderboard('weekly', 2, 10, 'hard');
expect(get).toHaveBeenCalledWith('/leaderboards', {
signal: undefined,
params: { period: 'weekly', page: 2, limit: 10, difficulty: 'hard' },
});
});
it('omits difficulty entirely when not given', async () => {
get.mockResolvedValue({ data: leaderboardResponse() });
await getLeaderboard('all-time', 1, 20);
const call = get.mock.calls[0]?.[1] as { params: Record<string, unknown> };
expect(call.params).not.toHaveProperty('difficulty');
});
});
describe('getUserStats', () => {
it('GETs /leaderboards/me with the daily default mode', async () => {
const data: UserStats = {
totalGamesPlayed: 5,
bestScore: 100,
averageScore: 50,
totalScore: 250,
currentStreak: 1,
longestStreak: 2,
recentScores: [],
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getUserStats(undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/leaderboards/me', {
params: { mode: 'daily' },
signal: controller.signal,
});
expect(result).toBe(data);
});
it('forwards an explicit mode', async () => {
get.mockResolvedValue({
data: {
totalGamesPlayed: 0,
bestScore: 0,
averageScore: 0,
totalScore: 0,
currentStreak: 0,
longestStreak: 0,
recentScores: [],
},
});
await getUserStats('all');
expect(get).toHaveBeenCalledWith('/leaderboards/me', { params: { mode: 'all' }, signal: undefined });
});
});
describe('checkDailyCompletion', () => {
it('GETs /leaderboards/me/today and returns the data', async () => {
const data: DailyCompletionStatus = { easy: true, medium: false, hard: false };
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await checkDailyCompletion(controller.signal);
expect(get).toHaveBeenCalledWith('/leaderboards/me/today', { signal: controller.signal });
expect(result).toBe(data);
});
});
+14 -13
View File
@@ -34,26 +34,27 @@ export interface UserStats {
}[];
}
export interface StreakInfo {
currentStreak: number;
longestStreak: number;
}
export async function getLeaderboard(
period: 'daily' | 'weekly' | 'all-time' = 'daily',
page = 1,
limit = 20,
difficulty?: string,
signal?: AbortSignal,
): Promise<LeaderboardResponse> {
const { data } = await apiClient.get<LeaderboardResponse>('/leaderboards', {
signal,
params: { period, page, limit, ...(difficulty && { difficulty }) },
});
return data;
}
export async function getUserStats(mode: 'all' | 'daily' = 'daily'): Promise<UserStats> {
export async function getUserStats(
mode: 'all' | 'daily' = 'daily',
signal?: AbortSignal,
): Promise<UserStats> {
const { data } = await apiClient.get<UserStats>('/leaderboards/me', {
params: { mode },
signal,
});
return data;
}
@@ -64,12 +65,12 @@ export interface DailyCompletionStatus {
hard: boolean;
}
export async function checkDailyCompletion(): Promise<DailyCompletionStatus> {
const { data } = await apiClient.get<DailyCompletionStatus>('/leaderboards/me/today');
return data;
}
export async function getUserStreak(): Promise<StreakInfo> {
const { data } = await apiClient.get<StreakInfo>('/leaderboards/me/streak');
export async function checkDailyCompletion(
signal?: AbortSignal,
): Promise<DailyCompletionStatus> {
const { data } = await apiClient.get<DailyCompletionStatus>(
'/leaderboards/me/today',
{ signal },
);
return data;
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest';
import type {
TmdbMovieCreditsResponse,
TmdbMovieDetailsResponse,
TmdbSearchMovieResponse,
} from '@/types';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { searchMovies, getMovieDetails, discoverMovies, getMovieCredits } = await import(
'./movies'
);
const searchResponse = (): TmdbSearchMovieResponse => ({
page: 1,
results: [],
total_pages: 1,
total_results: 0,
});
describe('searchMovies', () => {
it('GETs /movies/search with q and the default page', async () => {
const data = searchResponse();
get.mockResolvedValue({ data });
const result = await searchMovies('inception');
expect(get).toHaveBeenCalledWith('/movies/search', { params: { q: 'inception', page: 1 } });
expect(result).toBe(data);
});
it('passes an explicit page through', async () => {
get.mockResolvedValue({ data: searchResponse() });
await searchMovies('inception', 4);
expect(get).toHaveBeenCalledWith('/movies/search', { params: { q: 'inception', page: 4 } });
});
});
describe('getMovieDetails', () => {
it('GETs /movies/:id and returns the data', async () => {
const data: TmdbMovieDetailsResponse = {
id: 27205,
title: 'Inception',
original_title: 'Inception',
overview: '',
poster_path: null,
backdrop_path: null,
release_date: '2010-07-16',
popularity: 80,
vote_average: 8.4,
vote_count: 30000,
genre_ids: [],
adult: false,
runtime: 148,
budget: 160000000,
revenue: 830000000,
status: 'Released',
tagline: 'Your mind is the scene of the crime.',
genres: [],
};
get.mockResolvedValue({ data });
const result = await getMovieDetails(27205);
expect(get).toHaveBeenCalledWith('/movies/27205');
expect(result).toBe(data);
});
});
describe('discoverMovies', () => {
it('GETs /movies/discover with the default page', async () => {
const data = searchResponse();
get.mockResolvedValue({ data });
const result = await discoverMovies();
expect(get).toHaveBeenCalledWith('/movies/discover', { params: { page: 1 } });
expect(result).toBe(data);
});
it('passes an explicit page through', async () => {
get.mockResolvedValue({ data: searchResponse() });
await discoverMovies(2);
expect(get).toHaveBeenCalledWith('/movies/discover', { params: { page: 2 } });
});
});
describe('getMovieCredits', () => {
it('GETs /movies/:id/credits and returns the data', async () => {
const data: TmdbMovieCreditsResponse = { id: 27205, cast: [] };
get.mockResolvedValue({ data });
const result = await getMovieCredits(27205);
expect(get).toHaveBeenCalledWith('/movies/27205/credits');
expect(result).toBe(data);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import type { Notification } from './notifications';
const get = vi.fn();
const patch = vi.fn();
const post = vi.fn();
const del = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
patch: (...a: unknown[]) => patch(...a),
post: (...a: unknown[]) => post(...a),
delete: (...a: unknown[]) => del(...a),
},
}));
const {
getNotifications,
getUnreadCount,
markNotificationRead,
markAllNotificationsRead,
deleteNotification,
} = await import('./notifications');
const notif = (): Notification => ({
id: 'n1',
userId: 'u1',
type: 'friend-request',
title: 'Title',
message: 'Message',
data: null,
read: false,
createdAt: '2024-01-01T00:00:00Z',
});
describe('getNotifications', () => {
it('GETs /notifications with default page and limit', async () => {
const data = { data: [notif()], total: 1, unreadCount: 1, page: 1, limit: 20 };
get.mockResolvedValue({ data });
const result = await getNotifications();
expect(get).toHaveBeenCalledWith('/notifications', { params: { page: 1, limit: 20 } });
expect(result).toBe(data);
});
it('forwards an explicit page and limit', async () => {
get.mockResolvedValue({ data: { data: [], total: 0, unreadCount: 0, page: 2, limit: 5 } });
await getNotifications(2, 5);
expect(get).toHaveBeenCalledWith('/notifications', { params: { page: 2, limit: 5 } });
});
});
describe('getUnreadCount', () => {
it('GETs /notifications/unread-count and returns just the count', async () => {
get.mockResolvedValue({ data: { count: 7 } });
const result = await getUnreadCount();
expect(get).toHaveBeenCalledWith('/notifications/unread-count');
expect(result).toBe(7);
});
});
describe('markNotificationRead', () => {
it('PATCHes /notifications/:id/read', async () => {
patch.mockResolvedValue({ data: undefined });
await markNotificationRead('n1');
expect(patch).toHaveBeenCalledWith('/notifications/n1/read');
});
});
describe('markAllNotificationsRead', () => {
it('POSTs /notifications/read-all', async () => {
post.mockResolvedValue({ data: undefined });
await markAllNotificationsRead();
expect(post).toHaveBeenCalledWith('/notifications/read-all');
});
});
describe('deleteNotification', () => {
it('DELETEs /notifications/:id', async () => {
del.mockResolvedValue({ data: undefined });
await deleteNotification('n1');
expect(del).toHaveBeenCalledWith('/notifications/n1');
});
});
-4
View File
@@ -42,7 +42,3 @@ export async function markAllNotificationsRead(): Promise<void> {
export async function deleteNotification(id: string): Promise<void> {
await apiClient.delete(`/notifications/${id}`);
}
export async function deleteAllReadNotifications(): Promise<void> {
await apiClient.delete('/notifications/read');
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import type { TmdbPersonMovieCreditsResponse, TmdbSearchPersonResponse } from '@/types';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { searchPersons, getPersonMovieCredits } = await import('./persons');
describe('searchPersons', () => {
it('GETs /persons/search with q and the default page', async () => {
const data: TmdbSearchPersonResponse = {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
get.mockResolvedValue({ data });
const result = await searchPersons('tom hanks');
expect(get).toHaveBeenCalledWith('/persons/search', { params: { q: 'tom hanks', page: 1 } });
expect(result).toBe(data);
});
it('passes an explicit page through', async () => {
get.mockResolvedValue({ data: { page: 3, results: [], total_pages: 5, total_results: 90 } });
await searchPersons('tom hanks', 3);
expect(get).toHaveBeenCalledWith('/persons/search', { params: { q: 'tom hanks', page: 3 } });
});
});
describe('getPersonMovieCredits', () => {
it('GETs /persons/:id/movie-credits and returns the data', async () => {
const data: TmdbPersonMovieCreditsResponse = { id: 42, cast: [] };
get.mockResolvedValue({ data });
const result = await getPersonMovieCredits(42);
expect(get).toHaveBeenCalledWith('/persons/42/movie-credits');
expect(result).toBe(data);
});
});
-10
View File
@@ -1,7 +1,6 @@
import apiClient from './client';
import type {
TmdbSearchPersonResponse,
TmdbPersonDetailsResponse,
TmdbPersonMovieCreditsResponse,
} from '@/types';
@@ -16,15 +15,6 @@ export async function searchPersons(
return data;
}
export async function getPersonDetails(
id: number,
): Promise<TmdbPersonDetailsResponse> {
const { data } = await apiClient.get<TmdbPersonDetailsResponse>(
`/persons/${id}`,
);
return data;
}
export async function getPersonMovieCredits(
id: number,
): Promise<TmdbPersonMovieCreditsResponse> {
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
const patch = vi.fn();
vi.mock('@/api/client', () => ({
default: { patch: (...a: unknown[]) => patch(...a) },
}));
const { updateProfile, changePassword } = await import('./users');
describe('updateProfile', () => {
it('PATCHes /users/me with the given fields and returns the data', async () => {
const data = { id: 'u1', displayName: 'New Name' };
patch.mockResolvedValue({ data });
const result = await updateProfile({ displayName: 'New Name' });
expect(patch).toHaveBeenCalledWith('/users/me', { displayName: 'New Name' });
expect(result).toBe(data);
});
it('forwards an avatarUrl update', async () => {
patch.mockResolvedValue({ data: {} });
await updateProfile({ avatarUrl: 'https://example.com/a.png' });
expect(patch).toHaveBeenCalledWith('/users/me', { avatarUrl: 'https://example.com/a.png' });
});
});
describe('changePassword', () => {
it('PATCHes /users/me/password with both passwords and returns the data', async () => {
const data = { success: true };
patch.mockResolvedValue({ data });
const result = await changePassword('old-pw', 'new-pw');
expect(patch).toHaveBeenCalledWith('/users/me/password', {
currentPassword: 'old-pw',
newPassword: 'new-pw',
});
expect(result).toBe(data);
});
});
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it, vi } from 'vitest';
import type { AsyncAttemptResponse, AsyncLeaderboardResponse, VersusMatch } from './versus';
const get = vi.fn();
const post = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
},
}));
const {
createMatch,
getWaitingMatches,
joinMatch,
cancelMatch,
getMyWaitingMatches,
getMatch,
submitCreatorScore,
startAsyncAttempt,
submitAsyncAttempt,
getAsyncLeaderboard,
} = await import('./versus');
const match = (): VersusMatch => ({
id: 'm1',
movieAId: 1,
movieBId: 2,
difficulty: 'medium',
status: 'waiting',
lobbyName: null,
winnerId: null,
player1: { id: 'u1', username: 'alice' },
player2: null,
player1Score: null,
player2Score: null,
createdAt: '2024-01-01',
startedAt: null,
finishedAt: null,
});
describe('createMatch', () => {
it('POSTs /versus with a default difficulty and no lobby name/password', async () => {
const data = match();
post.mockResolvedValue({ data });
const result = await createMatch();
expect(post).toHaveBeenCalledWith('/versus', {
difficulty: 'medium',
lobbyName: undefined,
password: undefined,
isAsync: undefined,
});
expect(result).toBe(data);
});
it('forwards every field when given', async () => {
post.mockResolvedValue({ data: match() });
await createMatch('hard', 'Lobby', 'secret', true);
expect(post).toHaveBeenCalledWith('/versus', {
difficulty: 'hard',
lobbyName: 'Lobby',
password: 'secret',
isAsync: true,
});
});
});
describe('getWaitingMatches', () => {
it('GETs /versus/waiting with no params and the abort signal when nothing is given', async () => {
const data = [match()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getWaitingMatches(undefined, undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/versus/waiting', { params: {}, signal: controller.signal });
expect(result).toBe(data);
});
it('includes excludeUser and mode when given', async () => {
get.mockResolvedValue({ data: [] });
await getWaitingMatches('u1', 'daily');
expect(get).toHaveBeenCalledWith('/versus/waiting', {
params: { excludeUser: 'u1', mode: 'daily' },
signal: undefined,
});
});
});
describe('joinMatch', () => {
it('POSTs /versus/:id/join with a password', async () => {
const data = match();
post.mockResolvedValue({ data });
const result = await joinMatch('m1', 'secret');
expect(post).toHaveBeenCalledWith('/versus/m1/join', { password: 'secret' });
expect(result).toBe(data);
});
});
describe('cancelMatch', () => {
it('POSTs /versus/:id/cancel with no body', async () => {
post.mockResolvedValue({ data: undefined });
await cancelMatch('m1');
expect(post).toHaveBeenCalledWith('/versus/m1/cancel');
});
});
describe('getMyWaitingMatches', () => {
it('GETs /versus/me/waiting with the abort signal', async () => {
const data = [match()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getMyWaitingMatches(controller.signal);
expect(get).toHaveBeenCalledWith('/versus/me/waiting', { signal: controller.signal });
expect(result).toBe(data);
});
});
describe('getMatch', () => {
it('GETs /versus/:id with the abort signal', async () => {
const data = match();
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getMatch('m1', controller.signal);
expect(get).toHaveBeenCalledWith('/versus/m1', { signal: controller.signal });
expect(result).toBe(data);
});
});
describe('submitCreatorScore', () => {
it('POSTs /versus/:id/creator-score with the session id', async () => {
const data = match();
post.mockResolvedValue({ data });
const result = await submitCreatorScore('m1', 'session-1');
expect(post).toHaveBeenCalledWith('/versus/m1/creator-score', { sessionId: 'session-1' });
expect(result).toBe(data);
});
});
describe('startAsyncAttempt', () => {
it('POSTs /versus/:id/async-attempt with an optional password', async () => {
const data: AsyncAttemptResponse = {
attempt: { id: 'a1', matchId: 'm1', playerId: 'u1' },
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
chainLength: null,
};
post.mockResolvedValue({ data });
const result = await startAsyncAttempt('m1', 'secret');
expect(post).toHaveBeenCalledWith('/versus/m1/async-attempt', { password: 'secret' });
expect(result).toBe(data);
});
});
describe('submitAsyncAttempt', () => {
it('POSTs /versus/:id/async-attempt/submit with the session id and returns the data', async () => {
const data = { rank: 1 };
post.mockResolvedValue({ data });
const result = await submitAsyncAttempt('m1', 'session-1');
expect(post).toHaveBeenCalledWith('/versus/m1/async-attempt/submit', { sessionId: 'session-1' });
expect(result).toBe(data);
});
});
describe('getAsyncLeaderboard', () => {
it('GETs /versus/:id/leaderboard with the abort signal', async () => {
const data: AsyncLeaderboardResponse = {
match: { id: 'm1', difficulty: 'medium', lobbyName: null, expiresAt: null, status: 'open' },
creator: { id: 'u1', username: 'alice' },
leaderboard: [],
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getAsyncLeaderboard('m1', controller.signal);
expect(get).toHaveBeenCalledWith('/versus/m1/leaderboard', { signal: controller.signal });
expect(result).toBe(data);
});
});
+27 -14
View File
@@ -44,11 +44,15 @@ export async function createMatch(
export async function getWaitingMatches(
excludeUser?: string,
mode?: string,
signal?: AbortSignal,
): Promise<VersusMatch[]> {
const params: Record<string, string> = {};
if (excludeUser) params.excludeUser = excludeUser;
if (mode) params.mode = mode;
const { data } = await apiClient.get<VersusMatch[]>('/versus/waiting', { params });
const { data } = await apiClient.get<VersusMatch[]>('/versus/waiting', {
params,
signal,
});
return data;
}
@@ -63,19 +67,21 @@ export async function cancelMatch(matchId: string): Promise<void> {
await apiClient.post(`/versus/${matchId}/cancel`);
}
export async function getMyWaitingMatches(): Promise<VersusMatch[]> {
const { data } = await apiClient.get<VersusMatch[]>('/versus/me/waiting');
export async function getMyWaitingMatches(
signal?: AbortSignal,
): Promise<VersusMatch[]> {
const { data } = await apiClient.get<VersusMatch[]>('/versus/me/waiting', {
signal,
});
return data;
}
export async function getMatch(matchId: string): Promise<VersusMatch> {
const { data } = await apiClient.get<VersusMatch>(`/versus/${matchId}`);
return data;
}
export async function getUserMatchHistory(page = 1, limit = 20) {
const { data } = await apiClient.get('/versus/me/history', {
params: { page, limit },
export async function getMatch(
matchId: string,
signal?: AbortSignal,
): Promise<VersusMatch> {
const { data } = await apiClient.get<VersusMatch>(`/versus/${matchId}`, {
signal,
});
return data;
}
@@ -138,18 +144,25 @@ export interface AsyncLeaderboardResponse {
lobbyName: string | null;
expiresAt: string | null;
status: string;
movieATitle: string;
// Absent unless the viewer has played this match: the pair is the puzzle,
// so the server withholds it from anyone who could still be given it as a
// challenge. Render a placeholder rather than an empty heading.
movieATitle?: string;
movieAReleaseDate?: string | null;
movieBTitle: string;
movieBTitle?: string;
movieBReleaseDate?: string | null;
};
creator: { id: string; username: string };
leaderboard: LeaderboardEntry[];
}
export async function getAsyncLeaderboard(matchId: string): Promise<AsyncLeaderboardResponse> {
export async function getAsyncLeaderboard(
matchId: string,
signal?: AbortSignal,
): Promise<AsyncLeaderboardResponse> {
const { data } = await apiClient.get<AsyncLeaderboardResponse>(
`/versus/${matchId}/leaderboard`,
{ signal },
);
return data;
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,186 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { beforeEach, describe, expect, it } from 'vitest';
import { useGameStore } from '@/stores/game-store';
import { useGameNightStore } from '@/stores/game-night-store';
import GameNightCompletionModal from './GameNightCompletionModal';
import type { GameNightPlayer } from '@/api/game-night';
import type { ScoreBreakdown } from '@/types';
const score = (totalScore: number): ScoreBreakdown => ({
baseScore: 0,
chainLength: 0,
linkCount: 0,
chainLengthBonus: 0,
timeBonus: 0,
obscurityBonus: 0,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 0,
totalScore,
});
const player = (
id: string,
username: string,
overrides: Partial<GameNightPlayer> = {},
): GameNightPlayer => ({
id,
userId: id,
isReady: true,
isHost: false,
isSpectator: false,
score: null,
chainLength: 0,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: new Date().toISOString(),
completedAt: null,
user: { id, username },
...overrides,
});
function renderModal(initialEntry = '/game-night/room1') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/game-night/:roomId" element={<GameNightCompletionModal />} />
<Route path="/game-night" element={<div>lobby-list route</div>} />
<Route path="/game-night/:roomId/results" element={<div>results route</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
useGameStore.getState().resetGame();
useGameNightStore.setState({
lobbyState: 'idle',
players: [],
finishedPlayers: new Set(),
forfeitedPlayers: new Set(),
roomId: 'room1',
});
});
describe('GameNightCompletionModal', () => {
it('is closed while the chain is incomplete and the room has not finished', () => {
renderModal();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('opens with "Chain Complete!" once my chain is done, before the room finishes', () => {
useGameStore.setState({ status: 'completed', score: score(1200) });
renderModal();
expect(screen.getByRole('heading', { name: 'Chain Complete!' })).toBeInTheDocument();
expect(screen.getByText('1200')).toBeInTheDocument();
expect(screen.getByText('Your Score')).toBeInTheDocument();
});
it('shows no score panel when the store has none yet', () => {
useGameStore.setState({ status: 'completed', score: null });
renderModal();
expect(screen.queryByText('Your Score')).toBeNull();
});
it('lists the other active players with singular wording for exactly one waiting', () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Rival')],
finishedPlayers: new Set(['u1']),
});
renderModal();
expect(screen.getByText('Waiting for 1 player...')).toBeInTheDocument();
expect(screen.getByText('Rival')).toBeInTheDocument();
});
it('uses plural wording for more than one player still waiting', () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Rival'), player('u3', 'Third')],
finishedPlayers: new Set(['u1']),
});
renderModal();
expect(screen.getByText('Waiting for 2 players...')).toBeInTheDocument();
});
it('excludes spectators from the waiting count and the roster', () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Spec', { isSpectator: true })],
finishedPlayers: new Set(['u1']),
});
renderModal();
expect(screen.queryByText(/Waiting for/)).toBeNull();
expect(screen.queryByText('Spec')).toBeNull();
});
it('shows a forfeit flag, a finished check, and a spinner per player state', () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [
player('u1', 'Me'),
player('u2', 'Forfeiter'),
player('u3', 'StillGoing'),
],
finishedPlayers: new Set(['u1', 'u2']),
forfeitedPlayers: new Set(['u2']),
});
renderModal();
expect(screen.getByText('Forfeiter')).toBeInTheDocument();
expect(screen.getByText('StillGoing')).toBeInTheDocument();
// The dialog renders through a portal onto document.body, outside the
// render container, so icons are queried from the document.
expect(document.querySelector('.lucide-flag')).not.toBeNull();
expect(document.querySelector('.lucide-circle-check')).not.toBeNull();
expect(document.querySelector('.lucide-loader-circle')).not.toBeNull();
});
it('lets a waiting player leave, which resets game-night state and returns to the lobby list', async () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Rival')],
finishedPlayers: new Set(['u1']),
});
renderModal();
await userEvent.click(screen.getByRole('button', { name: 'Leave game' }));
expect(useGameNightStore.getState().roomId).toBeNull();
expect(useGameNightStore.getState().lobbyState).toBe('idle');
expect(screen.getByText('lobby-list route')).toBeInTheDocument();
});
it('shows "Game Over!" and a results button once the room has finished', async () => {
useGameNightStore.setState({ lobbyState: 'finished', roomId: 'room1' });
renderModal('/game-night/room1');
expect(screen.getByRole('heading', { name: 'Game Over!' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'View Results' }));
expect(screen.getByText('results route')).toBeInTheDocument();
});
it('does nothing when viewing results without a known room id', async () => {
useGameNightStore.setState({ lobbyState: 'finished', roomId: null });
renderModal('/game-night/room1');
await userEvent.click(screen.getByRole('button', { name: 'View Results' }));
expect(screen.queryByText('results route')).toBeNull();
});
it('hides the waiting roster once the room has finished, even if the map is stale', () => {
useGameNightStore.setState({
lobbyState: 'finished',
players: [player('u1', 'Me'), player('u2', 'Rival')],
finishedPlayers: new Set(['u1']),
});
renderModal();
expect(screen.queryByText(/Waiting for/)).toBeNull();
});
});
@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import PlayerCard from './PlayerCard';
import type { GameNightPlayer } from '@/api/game-night';
const player = (overrides: Partial<GameNightPlayer> = {}): GameNightPlayer => ({
id: 'p1',
userId: 'u1',
isReady: false,
isHost: false,
isSpectator: false,
score: null,
chainLength: 0,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: new Date().toISOString(),
completedAt: null,
user: { id: 'u1', username: 'Alice' },
...overrides,
});
describe('PlayerCard', () => {
it('shows the username', () => {
render(<PlayerCard player={player()} isCurrentUser={false} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('marks a host with a crown', () => {
render(<PlayerCard player={player({ isHost: true })} isCurrentUser={false} />);
expect(document.querySelector('.lucide-crown')).not.toBeNull();
});
it('shows a spectator badge instead of ready state', () => {
render(<PlayerCard player={player({ isSpectator: true })} isCurrentUser={false} />);
expect(screen.getByText('Spectator')).toBeInTheDocument();
});
it('shows a filled check when ready', () => {
render(<PlayerCard player={player({ isReady: true })} isCurrentUser={false} />);
expect(document.querySelector('.lucide-circle-check')).not.toBeNull();
});
it('shows an empty circle when not ready', () => {
render(<PlayerCard player={player({ isReady: false })} isCurrentUser={false} />);
expect(document.querySelector('.lucide-circle:not(.lucide-circle-check)')).not.toBeNull();
});
it('has no ready toggle for a spectator, even when a handler is given', () => {
const onToggleReady = vi.fn();
render(
<PlayerCard
player={player({ isSpectator: true })}
isCurrentUser={false}
onToggleReady={onToggleReady}
/>,
);
expect(screen.queryByRole('button')).toBeNull();
});
it('has no ready toggle when no handler is given', () => {
render(<PlayerCard player={player()} isCurrentUser={false} />);
expect(screen.queryByRole('button')).toBeNull();
});
it('offers a "Ready" toggle that calls the handler', async () => {
const onToggleReady = vi.fn();
render(
<PlayerCard player={player({ isReady: false })} isCurrentUser={false} onToggleReady={onToggleReady} />,
);
await userEvent.click(screen.getByRole('button', { name: 'Ready' }));
expect(onToggleReady).toHaveBeenCalledTimes(1);
});
it('offers an "Unready" toggle once ready', () => {
render(
<PlayerCard player={player({ isReady: true })} isCurrentUser={false} onToggleReady={vi.fn()} />,
);
expect(screen.getByRole('button', { name: 'Unready' })).toBeInTheDocument();
});
it('highlights the current user with a distinct border class', () => {
const { container: mine } = render(<PlayerCard player={player()} isCurrentUser />);
const { container: theirs } = render(<PlayerCard player={player()} isCurrentUser={false} />);
expect(mine.firstElementChild?.className).toContain('border-primary');
expect(theirs.firstElementChild?.className).toContain('border-border');
});
});
@@ -0,0 +1,114 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useGameNightStore } from '@/stores/game-night-store';
import PlayersProgress from './PlayersProgress';
import type { GameNightPlayer } from '@/api/game-night';
const player = (
id: string,
username: string,
overrides: Partial<GameNightPlayer> = {},
): GameNightPlayer => ({
id,
userId: id,
isReady: true,
isHost: false,
isSpectator: false,
score: null,
chainLength: 0,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: new Date().toISOString(),
completedAt: null,
user: { id, username },
...overrides,
});
beforeEach(() => {
useGameNightStore.setState({
players: [],
playerProgress: new Map(),
finishedPlayers: new Set(),
forfeitedPlayers: new Set(),
});
});
describe('PlayersProgress', () => {
it('shows each active player with their live link count', () => {
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Rival')],
playerProgress: new Map([
['u1', 3],
['u2', 0],
]),
});
render(<PlayersProgress />);
expect(screen.getByText('Me')).toBeInTheDocument();
expect(screen.getByText('3 links')).toBeInTheDocument();
expect(screen.getByText('0 links')).toBeInTheDocument();
});
it('excludes spectators from the roster', () => {
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Watcher', { isSpectator: true })],
});
render(<PlayersProgress />);
expect(screen.queryByText('Watcher')).toBeNull();
});
it('shows a "Done" badge for a finished player', () => {
useGameNightStore.setState({
players: [player('u1', 'Me')],
finishedPlayers: new Set(['u1']),
});
render(<PlayersProgress />);
expect(screen.getByText('Done')).toBeInTheDocument();
expect(document.querySelector('.lucide-circle-check')).not.toBeNull();
});
it('shows a "Forfeited" badge and takes priority over finished', () => {
useGameNightStore.setState({
players: [player('u1', 'Me')],
finishedPlayers: new Set(['u1']),
forfeitedPlayers: new Set(['u1']),
});
render(<PlayersProgress />);
expect(screen.getByText('Forfeited')).toBeInTheDocument();
expect(screen.queryByText('Done')).toBeNull();
expect(document.querySelector('.lucide-flag')).not.toBeNull();
});
it('shows a disconnected icon and a visually hidden note for a connected drop', () => {
useGameNightStore.setState({
players: [player('u1', 'Me', { disconnected: true })],
});
render(<PlayersProgress />);
expect(document.querySelector('.lucide-wifi-off')).not.toBeNull();
expect(screen.getByText('(disconnected)')).toBeInTheDocument();
});
it('does not show the disconnected note once a dropped player has finished', () => {
useGameNightStore.setState({
players: [player('u1', 'Me', { disconnected: true })],
finishedPlayers: new Set(['u1']),
});
render(<PlayersProgress />);
expect(screen.queryByText('(disconnected)')).toBeNull();
expect(document.querySelector('.lucide-circle-check')).not.toBeNull();
});
it('defaults an unreported chain length to zero', () => {
useGameNightStore.setState({ players: [player('u1', 'Me')] });
render(<PlayersProgress />);
expect(screen.getByText('0 links')).toBeInTheDocument();
});
it('shows a spinner for a connected player still playing', () => {
useGameNightStore.setState({ players: [player('u1', 'Me')] });
render(<PlayersProgress />);
expect(document.querySelector('.lucide-loader-circle')).not.toBeNull();
});
});
+11 -5
View File
@@ -13,7 +13,9 @@ export default function PlayersProgress() {
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold">Players</h3>
<div className="space-y-2">
{/* Polite, so a player finishing or forfeiting is announced without
interrupting what the user is doing. */}
<div className="space-y-2" aria-live="polite">
{activePlayers.map((player) => {
const chainLength = playerProgress.get(player.userId) ?? 0;
const isFinished = finishedPlayers.has(player.userId);
@@ -26,17 +28,21 @@ export default function PlayersProgress() {
>
<div className="flex items-center gap-2">
{isForfeited ? (
<Flag className="h-4 w-4 text-destructive" />
<Flag className="h-4 w-4 text-destructive" aria-hidden="true" />
) : isFinished ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" aria-hidden="true" />
) : player.disconnected ? (
<WifiOff className="h-4 w-4 text-muted-foreground" />
<WifiOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
) : (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden="true" />
)}
<span className="text-sm font-medium">
{player.user.username}
</span>
{/* Disconnection is the one state with no text badge. */}
{player.disconnected && !isFinished && !isForfeited && (
<span className="sr-only">(disconnected)</span>
)}
</div>
<div className="flex items-center gap-2">
{isForfeited ? (
+38
View File
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { ActorChainLink } from '@/types';
import ActorCard from './ActorCard';
const actor = (overrides: Partial<ActorChainLink> = {}): ActorChainLink => ({
type: 'actor',
id: 1,
name: 'Keanu Reeves',
profilePath: null,
popularity: 5,
...overrides,
});
describe('ActorCard', () => {
it('renders the full card with a placeholder image when there is no profile photo', () => {
render(<ActorCard actor={actor()} />);
const img = screen.getByRole('img', { name: 'Keanu Reeves' });
expect(img).toHaveAttribute('src', '/placeholder-person.svg');
expect(screen.getByText('Keanu Reeves')).toBeInTheDocument();
});
it('builds a TMDB image URL when a profile photo is known', () => {
render(<ActorCard actor={actor({ profilePath: '/keanu.jpg' })} />);
expect(screen.getByRole('img', { name: 'Keanu Reeves' })).toHaveAttribute(
'src',
'https://image.tmdb.org/t/p/w185/keanu.jpg',
);
});
it('renders the compact layout, still with a named image', () => {
render(<ActorCard actor={actor({ name: 'Carrie-Anne Moss' })} compact />);
expect(
screen.getByRole('img', { name: 'Carrie-Anne Moss' }),
).toBeInTheDocument();
expect(screen.getByText('Carrie-Anne Moss')).toBeInTheDocument();
});
});
@@ -0,0 +1,62 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CelebrationOverlay from './CelebrationOverlay';
/**
* Purely decorative confetti. Vestibular-disorder guidance says not to
* animate at all under prefers-reduced-motion, so the overlay must render
* nothing in that case rather than a still frame.
*/
function stubMatchMedia(matches: boolean) {
const mql = {
matches,
media: '(prefers-reduced-motion: reduce)',
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(mql));
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('CelebrationOverlay', () => {
it('renders confetti when the browser has no matchMedia support at all', () => {
// jsdom does not implement matchMedia by default; the component must
// treat that as "no preference" rather than throwing.
const { container } = render(<CelebrationOverlay />);
expect(container.firstElementChild).not.toBeNull();
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
expect(container.firstElementChild?.children).toHaveLength(20);
});
it('renders confetti when the user has not asked to reduce motion', () => {
stubMatchMedia(false);
const { container } = render(<CelebrationOverlay />);
expect(container.firstElementChild?.children).toHaveLength(20);
});
it('renders nothing under prefers-reduced-motion', () => {
stubMatchMedia(true);
const { container } = render(<CelebrationOverlay />);
expect(container).toBeEmptyDOMElement();
});
it('disappears on its own after three seconds', () => {
stubMatchMedia(false);
const { container } = render(<CelebrationOverlay />);
expect(container).not.toBeEmptyDOMElement();
act(() => {
vi.advanceTimersByTime(3000);
});
expect(container).toBeEmptyDOMElement();
});
});
+27 -3
View File
@@ -17,9 +17,14 @@ interface ConfettiPiece {
round: boolean;
}
/** Always in range (index < CONFETTI_COLORS.length); the fallback only satisfies the type. */
function randomConfettiColor(): string {
return CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)] ?? '#f44336';
}
function makeConfetti(): ConfettiPiece[] {
return Array.from({ length: CONFETTI_COUNT }, () => ({
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
color: randomConfettiColor(),
left: Math.random() * 100,
delay: Math.random() * 1,
duration: 2 + Math.random() * 1.5,
@@ -28,8 +33,24 @@ function makeConfetti(): ConfettiPiece[] {
}));
}
/**
* Twenty pieces of confetti falling for three seconds. Vestibular-disorder
* guidance is not to animate at all when the user has asked the OS not to, so
* the overlay renders nothing under `prefers-reduced-motion: reduce` rather
* than a still frame of coloured squares. It is purely decorative - the
* completion modal carries the actual news - so nothing is lost.
*/
function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
export default function CelebrationOverlay() {
const [visible, setVisible] = useState(true);
const [reduceMotion] = useState(prefersReducedMotion);
// Rolled once per mount. Randomising in the render body re-rolled every piece
// on each re-render, so confetti visibly jumped mid-flight.
const [pieces] = useState(makeConfetti);
@@ -39,10 +60,13 @@ export default function CelebrationOverlay() {
return () => clearTimeout(timer);
}, []);
if (!visible) return null;
if (!visible || reduceMotion) return null;
return (
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden">
<div
className="pointer-events-none fixed inset-0 z-50 overflow-hidden"
aria-hidden="true"
>
{pieces.map((piece, i) => (
<div
key={i}
+167
View File
@@ -0,0 +1,167 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import type { ActorChainLink, ChainLink, MovieChainLink } from '@/types';
const { useGameStore } = await import('@/stores/game-store');
const { default: ChainDisplay } = await import('./ChainDisplay');
const movieLink = (id: number, title = `Movie ${id}`): MovieChainLink => ({
type: 'movie',
id,
title,
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
});
const actorLink = (id: number, name = `Actor ${id}`): ActorChainLink => ({
type: 'actor',
id,
name,
profilePath: null,
popularity: 5,
});
const MOVIE_A = movieLink(100, 'Movie A');
const MOVIE_B = movieLink(200, 'Movie B');
beforeEach(() => {
useGameStore.getState().resetGame();
});
describe('ChainDisplay in static mode (a finished chain passed as props)', () => {
it('renders the starting movie and every actor/movie pair', () => {
const chain: ChainLink[] = [
MOVIE_A,
actorLink(1),
MOVIE_B,
actorLink(2),
MOVIE_A,
];
render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />);
expect(screen.getAllByRole('img', { name: 'Movie A' })).toHaveLength(2);
expect(screen.getByRole('img', { name: 'Movie B' })).toBeInTheDocument();
expect(screen.getByText('Actor 1')).toBeInTheDocument();
expect(screen.getByText('Actor 2')).toBeInTheDocument();
expect(screen.getByText('Loop Complete!')).toBeInTheDocument();
});
it('marks the movie B slot with a B badge and the closing movie A slot with an A badge', () => {
const chain: ChainLink[] = [
MOVIE_A,
actorLink(1),
MOVIE_B,
actorLink(2),
MOVIE_A,
];
render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />);
// Both badges render as "Movie {A|B}"-labelled single-letter spans.
expect(screen.getAllByText('B')).toHaveLength(1);
expect(screen.getAllByText('A')).toHaveLength(1);
});
it('shows no pending slot or validating spinner for a static chain', () => {
const chain: ChainLink[] = [MOVIE_A, actorLink(1)];
render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />);
expect(screen.queryByText('Validating...')).toBeNull();
expect(screen.queryByText('?')).toBeNull();
});
});
describe('ChainDisplay in live mode (reading the game store)', () => {
it('renders nothing extra for a fresh, empty chain', () => {
useGameStore.setState({ chain: [], movieA: null, movieB: null, status: 'idle' });
render(<ChainDisplay />);
expect(screen.queryByRole('img')).toBeNull();
});
it('shows a dashed pending slot after an actor while awaiting the next movie', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1)],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: false,
});
render(<ChainDisplay />);
expect(screen.getByText('?')).toBeInTheDocument();
expect(screen.queryByText('Validating...')).toBeNull();
});
it('shows a validating spinner in the pending slot while a movie is being checked', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1)],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: true,
});
render(<ChainDisplay />);
// Two "Validating..." labels: the pending-movie slot and (since the chain
// length here is even, not odd) not the standalone validating row.
expect(screen.getByText('Validating...')).toBeInTheDocument();
});
it('shows the standalone validating row while searching for the next actor', () => {
useGameStore.setState({
chain: [MOVIE_A],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: true,
});
render(<ChainDisplay />);
expect(screen.getByText('Validating...')).toBeInTheDocument();
});
it('shows the standalone validating row on an odd-length chain beyond the first pair', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1), movieLink(300)],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: true,
});
render(<ChainDisplay />);
expect(screen.getByText('Validating...')).toBeInTheDocument();
});
it('does not show the standalone validating row once the chain is complete', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1), MOVIE_B, actorLink(2), MOVIE_A],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'completed',
isValidating: false,
});
render(<ChainDisplay />);
expect(screen.queryByText('Validating...')).toBeNull();
expect(screen.getByText('Loop Complete!')).toBeInTheDocument();
});
it('badges the movie B link once the chain reaches it', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1), MOVIE_B],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: false,
});
render(<ChainDisplay />);
expect(screen.getByText('B')).toBeInTheDocument();
});
it('does not badge a mid-chain return to movie A before the loop actually closes', () => {
// Same id as movie A, but not the final pair and not completed: no badge.
useGameStore.setState({
chain: [MOVIE_A, actorLink(1), MOVIE_A, actorLink(2)],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: false,
});
render(<ChainDisplay />);
expect(screen.queryByText('A')).toBeNull();
});
});
+76 -11
View File
@@ -4,20 +4,83 @@ import ActorCard from './ActorCard';
import SerpentineConnector from './SerpentineConnector';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
import type { ActorChainLink, MovieChainLink } from '@/types';
import type {
ActorChainLink,
ChainLink,
GameStatus,
MovieChainLink,
} from '@/types';
interface ChainPair {
actor: ActorChainLink;
movie?: MovieChainLink;
}
export default function ChainDisplay() {
/**
* Without props the display is live: it follows the game store, animates the
* newest row and shows a pending slot while the next link is validated. Given
* a `chain`, it is a still of that finished chain (e.g. a game review) and
* reads nothing from the store.
*/
type ChainDisplayProps =
| { chain?: undefined; movieAId?: undefined; movieBId?: undefined }
| { chain: ChainLink[]; movieAId: number; movieBId: number };
export default function ChainDisplay(props: ChainDisplayProps) {
if (props.chain !== undefined) {
// A chain handed in as a prop is a finished game: the loop is complete
// and nothing is being validated.
return (
<ChainRows
chain={props.chain}
movieAId={props.movieAId}
movieBId={props.movieBId}
status="completed"
isValidating={false}
live={false}
/>
);
}
return <LiveChainDisplay />;
}
function LiveChainDisplay() {
const chain = useGameStore((s) => s.chain);
const movieA = useGameStore((s) => s.movieA);
const movieB = useGameStore((s) => s.movieB);
const status = useGameStore((s) => s.status);
const isValidating = useGameStore((s) => s.isValidating);
return (
<ChainRows
chain={chain}
movieAId={movieA?.id}
movieBId={movieB?.id}
status={status}
isValidating={isValidating}
live
/>
);
}
interface ChainRowsProps {
chain: ChainLink[];
movieAId: number | undefined;
movieBId: number | undefined;
status: GameStatus;
isValidating: boolean;
/** Store-driven: animate the newest row and show the pending movie slot. */
live: boolean;
}
function ChainRows({
chain,
movieAId,
movieBId,
status,
isValidating,
live,
}: ChainRowsProps) {
// chain[0] = MovieA (standalone starting point)
// chain[1..2] = pair 0 (Actor, Movie)
// chain[3..4] = pair 1 (Actor, Movie)
@@ -55,9 +118,9 @@ export default function ChainDisplay() {
// Determine movie target markers
let movieTarget: 'A' | 'B' | undefined;
if (pair.movie) {
if (pair.movie.id === movieB?.id) movieTarget = 'B';
if (pair.movie.id === movieBId) movieTarget = 'B';
else if (
pair.movie.id === movieA?.id &&
pair.movie.id === movieAId &&
isLastPair &&
status === 'completed'
)
@@ -94,9 +157,11 @@ export default function ChainDisplay() {
movieTarget === 'B' && 'border-green-500 bg-green-500/5',
movieTarget === 'A' && 'border-primary bg-primary/5',
!movieTarget && 'border-border bg-card',
isLastPair && !isComplete && 'animate-slide-in',
isLastPair && isComplete && status !== 'completed' && 'animate-slide-in',
isLastPair && status === 'completed' && 'animate-glow-pulse',
live && [
isLastPair && !isComplete && 'animate-slide-in',
isLastPair && isComplete && status !== 'completed' && 'animate-slide-in',
isLastPair && status === 'completed' && 'animate-glow-pulse',
],
)}
>
{isOdd ? (
@@ -105,7 +170,7 @@ export default function ChainDisplay() {
<div className="flex min-w-0 flex-1 items-center justify-center">
<ActorCard actor={pair.actor} compact />
</div>
<Dot muted={!isComplete} />
<Dot muted={live && !isComplete} />
<div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie ? (
<div className="flex items-center gap-1.5">
@@ -113,7 +178,7 @@ export default function ChainDisplay() {
{movieTarget && <TargetBadge target={movieTarget} />}
</div>
) : (
<PendingSlot loading={isValidating && status === 'playing'} />
live && <PendingSlot loading={isValidating && status === 'playing'} />
)}
</div>
</>
@@ -127,10 +192,10 @@ export default function ChainDisplay() {
<MovieCard movie={pair.movie} compact />
</div>
) : (
<PendingSlot loading={isValidating && status === 'playing'} />
live && <PendingSlot loading={isValidating && status === 'playing'} />
)}
</div>
<Dot muted={!isComplete} />
<Dot muted={live && !isComplete} />
<div className="flex min-w-0 flex-1 items-center justify-center">
<ActorCard actor={pair.actor} compact />
</div>
@@ -0,0 +1,291 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChainLink, MovieChainLink, ScoreBreakdown } from '@/types';
/**
* GameCompletionModal is a store-driven view over the completed game: the
* "calculating score" spinner, a scoring error, or the final score + share
* buttons, with Play Again / View Chain controls. Sound and navigation are
* mocked; everything else is driven by seeding the real game store per WP-19
* conventions.
*/
const playSound = vi.fn();
const navigateMock = vi.fn();
vi.mock('@/lib/sounds', () => ({
playSound: (...args: unknown[]) => playSound(...args),
}));
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
return { ...actual, useNavigate: () => navigateMock };
});
const { useGameStore } = await import('@/stores/game-store');
const { MemoryRouter } = await import('react-router');
const { default: GameCompletionModal } = await import('./GameCompletionModal');
const MOVIE_A: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
const MOVIE_B: MovieChainLink = {
type: 'movie',
id: 200,
title: 'Movie B',
posterPath: null,
releaseDate: '2001-01-01',
popularity: 5,
};
const CHAIN: ChainLink[] = [
MOVIE_A,
{ type: 'actor', id: 1, name: 'Actor One', profilePath: null, popularity: 5 },
MOVIE_B,
{ type: 'actor', id: 2, name: 'Actor Two', profilePath: null, popularity: 5 },
MOVIE_A,
];
const SCORE: ScoreBreakdown = {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 125,
totalScore: 2400,
};
function renderModal() {
return render(
<MemoryRouter>
<GameCompletionModal />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
useGameStore.getState().resetGame();
});
describe('GameCompletionModal', () => {
it('shows nothing before the game is completed', () => {
useGameStore.setState({ status: 'playing', movieA: MOVIE_A, movieB: MOVIE_B, chain: [MOVIE_A] });
renderModal();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('shows a calculating spinner immediately on completion, before the score arrives', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: null,
validationError: null,
});
renderModal();
expect(screen.getByRole('dialog', { name: 'Congratulations!' })).toBeInTheDocument();
expect(screen.getByText('Calculating score...')).toBeInTheDocument();
});
it('shows a scoring error instead of a spinner when scoring failed', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: null,
validationError: 'Failed to calculate score.',
});
renderModal();
expect(screen.getByText('Failed to calculate score.')).toBeInTheDocument();
expect(screen.queryByText('Calculating score...')).toBeNull();
// No score means no share buttons.
expect(screen.queryByRole('button', { name: /Copy Result/ })).toBeNull();
});
it('shows the score, elapsed time and share controls once scored', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
hintsUsed: 0,
});
renderModal();
expect(screen.getByText('2400')).toBeInTheDocument();
expect(screen.getByText('Completed in 2:05')).toBeInTheDocument();
expect(screen.queryByText(/hints? used/)).toBeNull();
expect(
screen.getByRole('button', { name: /Copy Result/ }),
).toBeInTheDocument();
});
it('reports how many hints were used, pluralised correctly', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
hintsUsed: 1,
});
const { rerender } = renderModal();
expect(screen.getByText('1 hint used')).toBeInTheDocument();
useGameStore.setState({ hintsUsed: 2 });
rerender(
<MemoryRouter>
<GameCompletionModal />
</MemoryRouter>,
);
expect(screen.getByText('2 hints used')).toBeInTheDocument();
});
it('plays the completion sound exactly once per completion, not on every re-render', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
const { rerender } = renderModal();
expect(playSound).toHaveBeenCalledWith('completion');
expect(playSound).toHaveBeenCalledTimes(1);
act(() => {
useGameStore.setState({ hintsUsed: 1 });
});
rerender(
<MemoryRouter>
<GameCompletionModal />
</MemoryRouter>,
);
expect(playSound).toHaveBeenCalledTimes(1);
});
it('plays the completion sound again for the next game that completes', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
expect(playSound).toHaveBeenCalledTimes(1);
act(() => {
useGameStore.setState({ status: 'playing' });
});
act(() => {
useGameStore.setState({ status: 'completed' });
});
expect(playSound).toHaveBeenCalledTimes(2);
});
it('marks a completed daily challenge as done in local storage', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
isDailyChallenge: true,
dailyChallengeDate: '2024-03-01',
dailyChallengeDifficulty: 'hard',
});
renderModal();
expect(localStorage.getItem('daily-completed-2024-03-01-hard')).toBe('true');
});
it('dismisses the modal on View Chain without resetting the game', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
act(() => {
screen.getByRole('button', { name: 'View Chain' }).click();
});
expect(screen.queryByRole('dialog')).toBeNull();
expect(useGameStore.getState().status).toBe('completed');
expect(navigateMock).not.toHaveBeenCalled();
});
it('dismisses when closed via the dialog itself (e.g. Escape), same as View Chain', async () => {
const user = userEvent.setup();
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).toBeNull();
expect(useGameStore.getState().status).toBe('completed');
});
it('resets the game and returns home on Play Again', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
act(() => {
screen.getByRole('button', { name: 'Play Again' }).click();
});
expect(useGameStore.getState().status).toBe('idle');
expect(navigateMock).toHaveBeenCalledWith('/');
});
it('reopens for a new completion after being dismissed for the previous one', async () => {
const user = userEvent.setup();
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
await user.click(screen.getByRole('button', { name: 'View Chain' }));
expect(screen.queryByRole('dialog')).toBeNull();
act(() => {
useGameStore.setState({ status: 'playing', score: null });
});
expect(screen.queryByRole('dialog')).toBeNull();
act(() => {
useGameStore.setState({ status: 'completed', score: SCORE });
});
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
+2 -7
View File
@@ -14,15 +14,10 @@ import type { GameMode } from './ShareableResult';
import CelebrationOverlay from './CelebrationOverlay';
import { playSound } from '@/lib/sounds';
import { storage } from '@/lib/storage';
import { formatTime } from '@/lib/format';
import { useNavigate } from 'react-router';
import { Loader2 } from 'lucide-react';
function formatElapsed(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function GameCompletionModal() {
const status = useGameStore((s) => s.status);
const score = useGameStore((s) => s.score);
@@ -111,7 +106,7 @@ export default function GameCompletionModal() {
<>
<ScoreDisplay score={score} />
<div className="mt-3 flex justify-center gap-4 text-sm text-muted-foreground">
<span>Completed in {formatElapsed(score.elapsedSeconds)}</span>
<span>Completed in {formatTime(score.elapsedSeconds)}</span>
{hintsUsed > 0 && (
<span>{hintsUsed} hint{hintsUsed > 1 ? 's' : ''} used</span>
)}
+81
View File
@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import type { ActorChainLink, MovieChainLink } from '@/types';
const { useGameStore } = await import('@/stores/game-store');
const { default: GameHeader } = await import('./GameHeader');
const movieA: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
const movieB: MovieChainLink = {
type: 'movie',
id: 200,
title: 'Movie B',
posterPath: null,
releaseDate: '2001-01-01',
popularity: 5,
};
const actor: ActorChainLink = {
type: 'actor',
id: 1,
name: 'Actor One',
profilePath: null,
popularity: 5,
};
beforeEach(() => {
useGameStore.getState().resetGame();
});
describe('GameHeader', () => {
it('renders nothing before both target movies are known', () => {
const { container } = render(<GameHeader />);
expect(container).toBeEmptyDOMElement();
});
it('shows both target movies and the chain link count', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA, actor, movieB, actor] });
render(<GameHeader />);
expect(screen.getByRole('img', { name: 'Movie A' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Movie B' })).toBeInTheDocument();
// getLinkCount([movieA, actor, movieB, actor]) = floor((4-1)/2) = 1
expect(screen.getByText('Chain: 1 links')).toBeInTheDocument();
});
it('shows the custom game badge only for a custom game', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], isCustomGame: true });
const { rerender } = render(<GameHeader />);
expect(screen.getByText('Custom Game')).toBeInTheDocument();
useGameStore.setState({ isCustomGame: false });
rerender(<GameHeader />);
expect(screen.queryByText('Custom Game')).toBeNull();
});
it('announces that Movie B has not yet been reached', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: false });
render(<GameHeader />);
// The visible label reads "Passed Movie B" in both states; the sr-only
// text is what actually distinguishes them for a screen reader.
const status = screen.getByRole('status');
expect(status).toHaveTextContent('Passed Movie B');
expect(screen.getByText('Movie B not yet reached')).toBeInTheDocument();
expect(screen.queryByText('Movie B reached')).toBeNull();
});
it('announces that Movie B has been reached', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: true });
render(<GameHeader />);
expect(screen.getByText('Movie B reached')).toBeInTheDocument();
expect(screen.queryByText('Movie B not yet reached')).toBeNull();
});
});
+10 -3
View File
@@ -27,19 +27,26 @@ export default function GameHeader() {
<Badge variant="secondary">Custom Game</Badge>
)}
<Badge variant="outline">Chain: {getLinkCount(chain)} links</Badge>
<div className="flex items-center gap-1.5 text-sm">
<div role="status" className="flex items-center gap-1.5 text-sm">
{passedMovieB ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" aria-hidden="true" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
<Circle className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
{/* The visible label reads "Passed Movie B" in both states; only the
icon and colour said which. A screen reader therefore heard the
wrong thing before B was reached. */}
<span
aria-hidden="true"
className={
passedMovieB ? 'text-green-600' : 'text-muted-foreground'
}
>
Passed Movie B
</span>
<span className="sr-only">
{passedMovieB ? 'Movie B reached' : 'Movie B not yet reached'}
</span>
</div>
<div className="ml-auto">
<Timer />
@@ -0,0 +1,228 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { ReadyGameScreen } from '@/hooks/use-game-screen';
import type { MovieChainLink, TmdbMovieResult, TmdbPersonResult } from '@/types';
/**
* GameSearchPanel is a layout/wiring component: it assembles SearchAutocomplete,
* ValidationFeedback and HintButton around the undo control and the loop hint.
* Each of those has its own dedicated test file exercising its real behaviour
* (API calls, debounce, the game store); here they are replaced with light
* stand-ins so this file tests only what GameSearchPanel itself is
* responsible for: which props reach each child, and the undo/loop-hint logic.
*/
vi.mock('./SearchAutocomplete', () => ({
default: (props: {
mode: string;
disabled?: boolean;
placeholder?: string;
onSelectActor: (p: TmdbPersonResult) => void;
onSelectMovie: (m: TmdbMovieResult) => void;
}) => (
<div data-testid="search-autocomplete">
<span>mode:{props.mode}</span>
<span>disabled:{String(!!props.disabled)}</span>
<span>placeholder:{props.placeholder}</span>
<button
type="button"
onClick={() =>
props.onSelectActor({
id: 7,
name: 'Picked Actor',
profile_path: null,
popularity: 5,
known_for_department: 'Acting',
known_for: [],
adult: false,
})
}
>
pick actor
</button>
<button
type="button"
onClick={() =>
props.onSelectMovie({
id: 300,
title: 'Picked Movie',
original_title: 'Picked Movie',
overview: '',
poster_path: null,
backdrop_path: null,
release_date: '2000-01-01',
popularity: 5,
vote_average: 0,
vote_count: 0,
genre_ids: [],
adult: false,
})
}
>
pick movie
</button>
</div>
),
}));
vi.mock('./ValidationFeedback', () => ({
default: () => <div data-testid="validation-feedback" />,
}));
vi.mock('./HintButton', () => ({
default: () => <div data-testid="hint-button" />,
}));
const { default: GameSearchPanel } = await import('./GameSearchPanel');
const movieA: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
const movieB: MovieChainLink = {
type: 'movie',
id: 200,
title: 'Movie B',
posterPath: null,
releaseDate: '2001-01-01',
popularity: 5,
};
function baseScreen(overrides: Partial<ReadyGameScreen> = {}): ReadyGameScreen {
return {
ready: true,
status: 'playing',
chain: [movieA],
currentSearchMode: 'actor',
passedMovieB: false,
isValidating: false,
undoLastLink: vi.fn(),
validateAndAddActor: vi.fn(),
validateAndAddMovie: vi.fn(),
searchLabel: 'Who was in "Movie A"?',
searchPlaceholder: 'Search for an actor...',
showLoopHint: false,
movieA,
movieB,
...overrides,
};
}
describe('GameSearchPanel', () => {
it('shows the contextual search label and forwards mode/placeholder to the search box', () => {
render(<GameSearchPanel screen={baseScreen()} />);
expect(screen.getByText('Who was in "Movie A"?')).toBeInTheDocument();
expect(screen.getByText('mode:actor')).toBeInTheDocument();
expect(
screen.getByText('placeholder:Search for an actor...'),
).toBeInTheDocument();
expect(screen.getByText('disabled:false')).toBeInTheDocument();
});
it('disables the search box while validating', () => {
render(<GameSearchPanel screen={baseScreen({ isValidating: true })} />);
expect(screen.getByText('disabled:true')).toBeInTheDocument();
});
it('forwards a picked actor and a picked movie to the screen callbacks', async () => {
const user = userEvent.setup();
const validateAndAddActor = vi.fn();
const validateAndAddMovie = vi.fn();
render(
<GameSearchPanel
screen={baseScreen({ validateAndAddActor, validateAndAddMovie })}
/>,
);
await user.click(screen.getByRole('button', { name: 'pick actor' }));
expect(validateAndAddActor).toHaveBeenCalledWith(
expect.objectContaining({ id: 7, name: 'Picked Actor' }),
);
await user.click(screen.getByRole('button', { name: 'pick movie' }));
expect(validateAndAddMovie).toHaveBeenCalledWith(
expect.objectContaining({ id: 300, title: 'Picked Movie' }),
);
});
it('disables Undo with only the starting movie in the chain, and enables it once a link has been added', () => {
const { rerender } = render(
<GameSearchPanel screen={baseScreen({ chain: [movieA] })} />,
);
expect(screen.getByRole('button', { name: /Undo/ })).toBeDisabled();
rerender(
<GameSearchPanel
screen={baseScreen({
chain: [
movieA,
{ type: 'actor', id: 1, name: 'Actor', profilePath: null, popularity: 5 },
],
})}
/>,
);
expect(screen.getByRole('button', { name: /Undo/ })).toBeEnabled();
});
it('disables Undo while validating even with a link in the chain', () => {
render(
<GameSearchPanel
screen={baseScreen({
isValidating: true,
chain: [
movieA,
{ type: 'actor', id: 1, name: 'Actor', profilePath: null, popularity: 5 },
],
})}
/>,
);
expect(screen.getByRole('button', { name: /Undo/ })).toBeDisabled();
});
it('calls undoLastLink when Undo is clicked', async () => {
const user = userEvent.setup();
const undoLastLink = vi.fn();
render(
<GameSearchPanel
screen={baseScreen({
undoLastLink,
chain: [
movieA,
{ type: 'actor', id: 1, name: 'Actor', profilePath: null, popularity: 5 },
],
})}
/>,
);
await user.click(screen.getByRole('button', { name: /Undo/ }));
expect(undoLastLink).toHaveBeenCalledTimes(1);
});
it('shows the close-the-loop nudge only when the screen says to', () => {
const { rerender } = render(
<GameSearchPanel screen={baseScreen({ showLoopHint: false })} />,
);
expect(screen.queryByText(/close the loop/)).toBeNull();
rerender(<GameSearchPanel screen={baseScreen({ showLoopHint: true })} />);
expect(
screen.getByText('Select "Movie A" to close the loop!'),
).toBeInTheDocument();
});
it('renders extra actions passed as children alongside Undo', () => {
render(
<GameSearchPanel screen={baseScreen()}>
<button type="button">Forfeit</button>
</GameSearchPanel>,
);
expect(screen.getByRole('button', { name: 'Forfeit' })).toBeInTheDocument();
});
it('renders the hint button and validation feedback slots', () => {
render(<GameSearchPanel screen={baseScreen()} />);
expect(screen.getByTestId('hint-button')).toBeInTheDocument();
expect(screen.getByTestId('validation-feedback')).toBeInTheDocument();
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { ReactNode } from 'react';
import SearchAutocomplete from './SearchAutocomplete';
import ValidationFeedback from './ValidationFeedback';
import HintButton from './HintButton';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Undo2 } from 'lucide-react';
import type { ReadyGameScreen } from '@/hooks/use-game-screen';
interface GameSearchPanelProps {
screen: ReadyGameScreen;
/** Further actions beside Undo, e.g. a forfeit button. */
children?: ReactNode;
}
/**
* The "build the next link" panel shared by every game screen: the contextual
* prompt, hint and undo controls, the search box, validation feedback and the
* close-the-loop nudge. Callers decide when it shows (playing, not spectating).
*/
export default function GameSearchPanel({ screen, children }: GameSearchPanelProps) {
const {
chain,
currentSearchMode,
isValidating,
undoLastLink,
validateAndAddActor,
validateAndAddMovie,
searchLabel,
searchPlaceholder,
showLoopHint,
movieA,
} = screen;
return (
<div className="space-y-3">
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{searchLabel}</label>
<div className="flex items-center gap-2">
<HintButton />
<Button
variant="ghost"
size="sm"
onClick={undoLastLink}
disabled={chain.length <= 1 || isValidating}
>
<Undo2 className="mr-1 h-3.5 w-3.5" />
Undo
</Button>
{children}
</div>
</div>
<SearchAutocomplete
mode={currentSearchMode}
onSelectActor={validateAndAddActor}
onSelectMovie={validateAndAddMovie}
disabled={isValidating}
placeholder={searchPlaceholder}
/>
<ValidationFeedback />
{showLoopHint && (
<p className="text-sm text-green-600">
Select &quot;{movieA.title}&quot; to close the loop!
</p>
)}
</div>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { MovieChainLink } from '@/types';
/**
* HintButton composes useHints (which calls POST /games/:id/hint) with
* useChainValidation (POST /games/:id/validate-link). Both hooks have their
* own dedicated test suites; this covers what HintButton itself renders and
* wires up. Requests are intercepted at the axios client and routed by URL,
* same seam as game-store.test.ts, so nothing reaches the network.
*/
const requestHint = vi.fn();
const validateLinkApi = vi.fn();
const playSound = vi.fn();
type Handler = (body: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'post' && (m = /^\/games\/([^/]+)\/hint$/.exec(url))) {
return () => requestHint(m![1]);
}
if (method === 'post' && (m = /^\/games\/([^/]+)\/validate-link$/.exec(url))) {
return (body) => validateLinkApi(m![1], body);
}
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string, body?: unknown): Promise<{ data: unknown }> => ({
data: await route(method, url)(body),
});
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
vi.mock('@/lib/sounds', () => ({
playSound: (...args: unknown[]) => playSound(...args),
}));
const { useGameStore } = await import('@/stores/game-store');
const { default: HintButton } = await import('./HintButton');
const movieA: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
beforeEach(() => {
vi.clearAllMocks();
useGameStore.getState().resetGame();
validateLinkApi.mockResolvedValue({
valid: true,
chain: [],
loopComplete: false,
passedMovieB: false,
});
});
function playingWithSession() {
useGameStore.setState({
status: 'playing',
movieA,
chain: [movieA],
currentSearchMode: 'actor',
sessionId: 's1',
hintsUsed: 0,
});
}
describe('HintButton', () => {
it('shows the remaining hint count and the point cost', () => {
playingWithSession();
render(<HintButton />);
expect(
screen.getByRole('button', { name: /Hint \(3 left\)/ }),
).toBeInTheDocument();
// The server charges 200 per hint (score.service.ts); the label used to
// say 150, the duplicate penalty's figure. Seen live in WP-19's smoke run.
expect(screen.getByText('-200 pts')).toBeInTheDocument();
});
it('is disabled when a hint cannot be taken (no session)', () => {
useGameStore.setState({
status: 'playing',
movieA,
chain: [movieA],
currentSearchMode: 'actor',
sessionId: null,
});
render(<HintButton />);
expect(screen.getByRole('button', { name: /Hint/ })).toBeDisabled();
});
it('is disabled when the search mode is not looking for an actor', () => {
useGameStore.setState({
status: 'playing',
movieA,
chain: [movieA],
currentSearchMode: 'movie',
sessionId: 's1',
});
render(<HintButton />);
expect(screen.getByRole('button', { name: /Hint/ })).toBeDisabled();
});
it('fetches and lists hinted actors, updating the remaining count', async () => {
playingWithSession();
requestHint.mockResolvedValue({
hintsUsed: 1,
hintsRemaining: 2,
actors: [
{ id: 7, name: 'Hint Actor', profilePath: null, popularity: 5 },
],
});
const user = userEvent.setup();
render(<HintButton />);
await user.click(screen.getByRole('button', { name: /Hint \(3 left\)/ }));
expect(requestHint).toHaveBeenCalledWith('s1');
expect(
screen.getByRole('button', { name: /^Hint Actor/ }),
).toBeInTheDocument();
expect(useGameStore.getState().hintsUsed).toBe(1);
expect(
screen.getByRole('button', { name: /Hint \(2 left\)/ }),
).toBeInTheDocument();
});
it('adds the chosen hint to the chain and clears the hint list', async () => {
playingWithSession();
requestHint.mockResolvedValue({
hintsUsed: 1,
hintsRemaining: 2,
actors: [
{ id: 7, name: 'Hint Actor', profilePath: null, popularity: 9 },
],
});
const user = userEvent.setup();
render(<HintButton />);
await user.click(screen.getByRole('button', { name: /Hint \(3 left\)/ }));
await user.click(screen.getByRole('button', { name: /^Hint Actor/ }));
expect(validateLinkApi).toHaveBeenCalledWith('s1', {
type: 'actor',
id: 7,
name: 'Hint Actor',
popularity: 9,
});
expect(useGameStore.getState().chain).toHaveLength(2);
expect(useGameStore.getState().chain[1]).toMatchObject({
type: 'actor',
id: 7,
name: 'Hint Actor',
});
expect(playSound).toHaveBeenCalledWith('valid');
expect(screen.queryByRole('button', { name: /^Hint Actor/ })).toBeNull();
});
it('disables the button while a hint request is in flight', async () => {
playingWithSession();
let release: (v: unknown) => void = () => undefined;
requestHint.mockImplementation(
() => new Promise((resolve) => (release = resolve)),
);
const user = userEvent.setup();
render(<HintButton />);
const button = screen.getByRole('button', { name: /Hint \(3 left\)/ });
const clickPromise = user.click(button);
await vi.waitFor(() => expect(button).toBeDisabled());
release({ hintsUsed: 1, hintsRemaining: 2, actors: [] });
await clickPromise;
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ export default function HintButton() {
<Lightbulb className="mr-1 h-3.5 w-3.5" />
)}
Hint ({remaining} left)
<span className="ml-1 text-xs text-red-500">-150 pts</span>
<span className="ml-1 text-xs text-red-500">-200 pts</span>
</Button>
{hints.length > 0 && (
+55
View File
@@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { MovieChainLink } from '@/types';
import MovieCard from './MovieCard';
const movie = (overrides: Partial<MovieChainLink> = {}): MovieChainLink => ({
type: 'movie',
id: 1,
title: 'The Matrix',
posterPath: null,
releaseDate: '1999-03-31',
popularity: 5,
...overrides,
});
describe('MovieCard', () => {
it('renders a placeholder poster when there is none, with the release year', () => {
render(<MovieCard movie={movie()} />);
const img = screen.getByRole('img', { name: 'The Matrix' });
expect(img).toHaveAttribute('src', '/placeholder-movie.svg');
expect(screen.getByText('1999')).toBeInTheDocument();
});
it('builds a TMDB image URL when a poster is known', () => {
render(<MovieCard movie={movie({ posterPath: '/matrix.jpg' })} />);
expect(screen.getByRole('img', { name: 'The Matrix' })).toHaveAttribute(
'src',
'https://image.tmdb.org/t/p/w154/matrix.jpg',
);
});
it('labels the card as Movie A or Movie B when it is a target', () => {
const { rerender } = render(<MovieCard movie={movie()} isTarget="A" />);
expect(screen.getByText('Movie A')).toBeInTheDocument();
rerender(<MovieCard movie={movie()} isTarget="B" />);
expect(screen.getByText('Movie B')).toBeInTheDocument();
expect(screen.queryByText('Movie A')).toBeNull();
});
it('shows no target badge by default, highlighted or not', () => {
const { rerender } = render(<MovieCard movie={movie()} />);
expect(screen.queryByText(/^Movie [AB]$/)).toBeNull();
rerender(<MovieCard movie={movie()} highlight />);
expect(screen.queryByText(/^Movie [AB]$/)).toBeNull();
});
it('renders the compact layout without a target badge', () => {
render(<MovieCard movie={movie({ title: 'Speed' })} isTarget="A" compact />);
expect(screen.getByRole('img', { name: 'Speed' })).toBeInTheDocument();
expect(screen.getByText('Speed')).toBeInTheDocument();
expect(screen.queryByText('Movie A')).toBeNull();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { ScoreBreakdown } from '@/types';
import ScoreDisplay from './ScoreDisplay';
const SCORE: ScoreBreakdown = {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 300,
totalScore: 2400,
};
describe('ScoreDisplay', () => {
it('shows the total score and each bonus line', () => {
render(<ScoreDisplay score={SCORE} />);
expect(screen.getByText('2400')).toBeInTheDocument();
expect(screen.getByText('points')).toBeInTheDocument();
expect(screen.getByText('Base score')).toBeInTheDocument();
expect(screen.getByText('1000')).toBeInTheDocument();
expect(screen.getByText('+800')).toBeInTheDocument();
expect(screen.getByText('+500')).toBeInTheDocument();
expect(screen.getByText('+100')).toBeInTheDocument();
});
it('pluralises the link count and omits par when there is none', () => {
render(<ScoreDisplay score={SCORE} />);
expect(screen.getByText('Chain length (2 links)')).toBeInTheDocument();
});
it('uses the singular "link" for a single link, and shows par when present', () => {
render(<ScoreDisplay score={{ ...SCORE, linkCount: 1, par: 3 }} />);
expect(screen.getByText('Chain length (1 link, par 3)')).toBeInTheDocument();
});
it('hides the duplicate and hint penalty lines when they are zero', () => {
render(<ScoreDisplay score={SCORE} />);
expect(screen.queryByText('Duplicate penalty')).toBeNull();
expect(screen.queryByText('Hint penalty')).toBeNull();
});
it('shows a negative penalty line when duplicate or hint penalties apply', () => {
render(
<ScoreDisplay score={{ ...SCORE, duplicatePenalty: 150, hintPenalty: 150 }} />,
);
expect(screen.getByText('Duplicate penalty')).toBeInTheDocument();
expect(screen.getByText('Hint penalty')).toBeInTheDocument();
expect(screen.getAllByText('-150')).toHaveLength(2);
});
it('does not prefix a zero or negative bonus value with a plus sign', () => {
render(<ScoreDisplay score={{ ...SCORE, timeBonus: 0 }} />);
// The bonus line for time renders "0", not "+0".
const line = screen.getByText('Time bonus').closest('div');
expect(line).toHaveTextContent('Time bonus0');
});
});
@@ -0,0 +1,560 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TmdbMovieResult, TmdbPersonResult } from '@/types';
/**
* The ARIA 1.2 combobox that drives every "who was in..." / "what movie was
* ... in" search step. Requests are intercepted at the axios client and
* routed by URL - the same seam game-store.test.ts uses - so nothing reaches
* the network; searchMovies/searchPersons run for real against the mock.
*
* Real timers throughout: combining `vi.useFakeTimers()` with
* `@testing-library/user-event` deadlocks every `user.type`/`user.click` call
* in this project's toolchain (vitest 5.0.0 + user-event 14.6.7 + jsdom 30) -
* reproduced even for a bare `<input>` with no debounce involved, regardless
* of `delay: null` or an `advanceTimers` callback. `waitFor`/`findBy*` (which
* poll on real timers) stand in for fake-timer control of the 300ms debounce.
*/
const searchPersonsApi = vi.fn();
const searchMoviesApi = vi.fn();
type Handler = (params: Record<string, unknown> | undefined) => Promise<unknown>;
function route(method: string, url: string): Handler {
if (method === 'get' && url === '/persons/search') {
return (params) => searchPersonsApi((params as { q: string }).q);
}
if (method === 'get' && url === '/movies/search') {
return (params) => searchMoviesApi((params as { q: string }).q);
}
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (
url: string,
config?: { params?: Record<string, unknown> },
): Promise<{ data: unknown }> => ({
data: await route(method, url)(config?.params),
});
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: SearchAutocomplete } = await import('./SearchAutocomplete');
const person = (id: number, name = `Person ${id}`): TmdbPersonResult => ({
id,
name,
profile_path: null,
popularity: 5,
known_for_department: 'Acting',
known_for: [],
adult: false,
});
const tmdbMovie = (id: number, title = `Movie ${id}`): TmdbMovieResult => ({
id,
title,
original_title: title,
overview: '',
poster_path: null,
backdrop_path: null,
release_date: '2000-01-01',
popularity: 5,
vote_average: 7,
vote_count: 10,
genre_ids: [],
adult: false,
});
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Comfortably past the component's 300ms debounce. */
const pastDebounce = () => wait(400);
beforeEach(() => {
vi.clearAllMocks();
searchPersonsApi.mockResolvedValue({ page: 1, results: [], total_pages: 1, total_results: 0 });
searchMoviesApi.mockResolvedValue({ page: 1, results: [], total_pages: 1, total_results: 0 });
});
describe('SearchAutocomplete', () => {
it('does not search below the two-character threshold', async () => {
const user = userEvent.setup();
const onSelectActor = vi.fn();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'a');
await pastDebounce();
expect(searchPersonsApi).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).toBeNull();
}, 10000);
it('debounces before searching: not called right after typing, called once settled', async () => {
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'ke');
expect(searchPersonsApi).not.toHaveBeenCalled();
await waitFor(() => expect(searchPersonsApi).toHaveBeenCalledWith('ke'));
}, 10000);
it('opens a labelled listbox of actor results, wired to the combobox', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'Keanu Reeves'), person(2, 'Carrie-Anne Moss')],
total_pages: 1,
total_results: 2,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox', { name: 'Search for an actor' });
await user.type(input, 'ke');
const listbox = await screen.findByRole('listbox', { name: 'Actor results' });
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(input).toHaveAttribute('aria-controls', listbox.id);
expect(screen.getByText('Keanu Reeves')).toBeInTheDocument();
expect(screen.getByText('Carrie-Anne Moss')).toBeInTheDocument();
}, 10000);
it('filters out results that are not in the Acting department', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [
person(1, 'An Actor'),
{ ...person(2, 'A Director'), known_for_department: 'Directing' },
],
total_pages: 1,
total_results: 2,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'an');
await screen.findByText('An Actor');
expect(screen.queryByText('A Director')).toBeNull();
}, 10000);
it('searches movies in movie mode', async () => {
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(1, 'The Matrix')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox', { name: 'Search for a movie' }), 'ma');
await waitFor(() => expect(searchMoviesApi).toHaveBeenCalledWith('ma'));
expect(searchPersonsApi).not.toHaveBeenCalled();
expect(
screen.getByRole('listbox', { name: 'Movie results' }),
).toBeInTheDocument();
expect(screen.getByText('The Matrix')).toBeInTheDocument();
}, 10000);
it('shows "No results found" when a search comes back empty', async () => {
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'zz');
await screen.findByText('No results found');
}, 10000);
it('shows an error option when the search request fails', async () => {
searchPersonsApi.mockRejectedValue(new Error('network down'));
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'ke');
await screen.findByText('Search failed. Please try again.');
}, 10000);
it('shows a loading indicator while the request is in flight, then hides it', async () => {
let resolveSearch: (v: unknown) => void = () => undefined;
searchPersonsApi.mockImplementation(
() => new Promise((resolve) => (resolveSearch = resolve)),
);
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'ke');
await screen.findByLabelText('Searching...');
resolveSearch({ page: 1, results: [], total_pages: 1, total_results: 0 });
await waitFor(() => expect(screen.queryByLabelText('Searching...')).toBeNull());
}, 10000);
it('ignores a stale response that resolves after a newer search has started', async () => {
let resolveFirst: (v: unknown) => void = () => undefined;
let resolveSecond: (v: unknown) => void = () => undefined;
searchPersonsApi
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
.mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve)));
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'ab');
await waitFor(() => expect(searchPersonsApi).toHaveBeenCalledTimes(1));
await user.type(input, 'c');
await waitFor(() => expect(searchPersonsApi).toHaveBeenCalledTimes(2));
// The newer ("abc") request settles first.
resolveSecond({
page: 1,
results: [person(2, 'Current Result')],
total_pages: 1,
total_results: 1,
});
await screen.findByText('Current Result');
// The stale ("ab") request settles after: its cleanup already ran, so it
// must not clobber the current results.
resolveFirst({
page: 1,
results: [person(1, 'Stale Result')],
total_pages: 1,
total_results: 1,
});
await wait(50);
expect(screen.getByText('Current Result')).toBeInTheDocument();
expect(screen.queryByText('Stale Result')).toBeNull();
}, 10000);
it('navigates results with ArrowDown/ArrowUp, wrapping at each end', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First'), person(2, 'Second')],
total_pages: 1,
total_results: 2,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'ke');
const listbox = await screen.findByRole('listbox');
expect(input).not.toHaveAttribute('aria-activedescendant');
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-0`);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-1`);
// Wraps back to the first option.
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-0`);
// Wraps to the last option going up from the first.
await user.keyboard('{ArrowUp}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-1`);
}, 10000);
it('selects the active option on Enter, clears the query and closes the list', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(7, 'Seven')],
total_pages: 1,
total_results: 1,
});
const onSelectActor = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'se');
await screen.findByText('Seven');
await user.keyboard('{ArrowDown}{Enter}');
expect(onSelectActor).toHaveBeenCalledWith(expect.objectContaining({ id: 7, name: 'Seven' }));
expect(input).toHaveValue('');
expect(input).toHaveAttribute('aria-expanded', 'false');
}, 10000);
it('does nothing on Enter when no option is active', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(7, 'Seven')],
total_pages: 1,
total_results: 1,
});
const onSelectActor = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'se');
await screen.findByText('Seven');
await user.keyboard('{Enter}');
expect(onSelectActor).not.toHaveBeenCalled();
}, 10000);
it('selects an actor result by clicking it', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(3, 'Clicked Actor')],
total_pages: 1,
total_results: 1,
});
const onSelectActor = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'cl');
await screen.findByText('Clicked Actor');
await user.click(screen.getByRole('button', { name: /Clicked Actor/ }));
expect(onSelectActor).toHaveBeenCalledWith(
expect.objectContaining({ id: 3, name: 'Clicked Actor' }),
);
}, 10000);
it("shows an actor's known-for titles when TMDB reports any", async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [
{
...person(4, 'Prolific Actor'),
known_for: [tmdbMovie(1, 'Movie One'), tmdbMovie(2, 'Movie Two')],
},
],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'pr');
await screen.findByText('Prolific Actor');
expect(screen.getByText('Known for: Movie One, Movie Two')).toBeInTheDocument();
}, 10000);
it('selects the active movie option on Enter in movie mode', async () => {
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(9, 'Speed')],
total_pages: 1,
total_results: 1,
});
const onSelectMovie = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={onSelectMovie} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'sp');
await screen.findByText('Speed');
await user.keyboard('{ArrowDown}{Enter}');
expect(onSelectMovie).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, title: 'Speed' }),
);
expect(input).toHaveValue('');
}, 10000);
it('selects a movie result by clicking it', async () => {
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(9, 'Speed')],
total_pages: 1,
total_results: 1,
});
const onSelectMovie = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={onSelectMovie} />,
);
await user.type(screen.getByRole('combobox'), 'sp');
await screen.findByText('Speed');
await user.click(screen.getByRole('button', { name: /Speed/ }));
expect(onSelectMovie).toHaveBeenCalledWith(expect.objectContaining({ id: 9, title: 'Speed' }));
}, 10000);
it('closes and resets the active option on Escape', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'fi');
await screen.findByRole('listbox');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Escape}');
expect(screen.queryByRole('listbox')).toBeNull();
expect(input).not.toHaveAttribute('aria-activedescendant');
}, 10000);
it('closes the dropdown when clicking outside the component', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<div>
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />
<button type="button">outside</button>
</div>,
);
await user.type(screen.getByRole('combobox'), 'fi');
await screen.findByRole('listbox');
await user.click(screen.getByRole('button', { name: 'outside' }));
expect(screen.queryByRole('listbox')).toBeNull();
}, 10000);
it('reopens the dropdown on focus when there are already results', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<div>
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />
<button type="button">outside</button>
</div>,
);
const input = screen.getByRole('combobox');
await user.type(input, 'fi');
await screen.findByRole('listbox');
await user.click(screen.getByRole('button', { name: 'outside' }));
expect(screen.queryByRole('listbox')).toBeNull();
await user.click(input);
expect(screen.getByRole('listbox')).toBeInTheDocument();
}, 10000);
it('disables the input and forwards a custom placeholder', () => {
render(
<SearchAutocomplete
mode="actor"
onSelectActor={vi.fn()}
onSelectMovie={vi.fn()}
disabled
placeholder="Custom prompt"
/>,
);
const input = screen.getByRole('combobox');
expect(input).toBeDisabled();
expect(input).toHaveAttribute('placeholder', 'Custom prompt');
});
it('resets its query and results when the mode prop switches, and refocuses', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
const { rerender } = render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const actorInput = screen.getByRole('combobox');
await user.type(actorInput, 'fi');
await screen.findByRole('listbox');
expect(actorInput).toHaveValue('fi');
rerender(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const movieInput = screen.getByRole('combobox', { name: 'Search for a movie' });
expect(movieInput).toHaveValue('');
expect(screen.queryByRole('listbox')).toBeNull();
expect(movieInput).toHaveFocus();
}, 10000);
it('does not search the new mode with the previous query after a selection flips the mode', async () => {
// Seen live in WP-19's smoke run: pick an actor, the mode flips to movie,
// and 300ms of debounce still hold the actor's name - so a movie search
// for "Daniel Craig" ran and opened a listbox under an empty input.
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'Daniel Craig')],
total_pages: 1,
total_results: 1,
});
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(9, 'Daniel Craig - Permis de jouer')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
const onSelectActor = vi.fn();
const { rerender } = render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'Daniel Craig');
await screen.findByText('Daniel Craig');
await user.click(screen.getByRole('button', { name: /Daniel Craig/ }));
expect(onSelectActor).toHaveBeenCalledTimes(1);
// The parent reacts to the selection by switching the mode, as the game does.
rerender(
<SearchAutocomplete mode="movie" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await pastDebounce();
expect(searchMoviesApi).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).toBeNull();
expect(screen.getByRole('combobox', { name: 'Search for a movie' })).toHaveValue('');
}, 10000);
});
+25 -14
View File
@@ -31,7 +31,13 @@ export default function SearchAutocomplete({
const [movieResults, setMovieResults] = useState<TmdbMovieResult[]>([]);
const [personResults, setPersonResults] = useState<TmdbPersonResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
// The debounced value lags the live one by 300ms. When a selection clears
// the input and flips the mode, the search effect re-ran for the new mode
// with the *old* debounced query still in hand, and opened a listbox of
// movie results for the actor's name under an empty input. An empty live
// query therefore means "nothing to search", whatever the debounce holds.
const debouncedQuery = useDebounce(query, 300);
const searchQuery = query.trim().length === 0 ? '' : debouncedQuery;
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listId = `search-listbox-${mode}`;
@@ -39,7 +45,7 @@ export default function SearchAutocomplete({
// A query too short to search is a derived condition. Clearing results in an
// effect meant an extra committed render on every keystroke below the
// threshold, and left the stored results and the displayed ones out of step.
const isSearchable = debouncedQuery.trim().length >= 2;
const isSearchable = searchQuery.trim().length >= 2;
const results = isSearchable
? mode === 'actor'
? personResults
@@ -48,7 +54,7 @@ export default function SearchAutocomplete({
// Loading and error are derived from which search has settled, so neither is
// set synchronously inside the effect.
const searchKey = `${mode}:${debouncedQuery}`;
const searchKey = `${mode}:${searchQuery}`;
const [settled, setSettled] = useState<{
key: string;
error: string | null;
@@ -65,7 +71,7 @@ export default function SearchAutocomplete({
let error: string | null = null;
try {
if (mode === 'actor') {
const data = await searchPersons(debouncedQuery);
const data = await searchPersons(searchQuery);
if (cancelled) return;
setPersonResults(
data.results.filter((p) => p.known_for_department === 'Acting'),
@@ -74,7 +80,7 @@ export default function SearchAutocomplete({
setIsOpen(true);
setActiveIndex(-1);
} else {
const data = await searchMovies(debouncedQuery);
const data = await searchMovies(searchQuery);
if (cancelled) return;
setMovieResults(data.results);
setPersonResults([]);
@@ -93,7 +99,7 @@ export default function SearchAutocomplete({
return () => {
cancelled = true;
};
}, [debouncedQuery, mode, isSearchable, searchKey]);
}, [searchQuery, mode, isSearchable, searchKey]);
// Reset when the mode flips between actor and movie. Adjusting state during
// render is React's documented pattern for reacting to a changed prop; doing
@@ -162,12 +168,12 @@ export default function SearchAutocomplete({
break;
case 'Enter':
e.preventDefault();
if (activeIndex >= 0 && activeIndex < results.length) {
if (mode === 'actor') {
handleSelectPerson(personResults[activeIndex]);
} else {
handleSelectMovie(movieResults[activeIndex]);
}
if (mode === 'actor') {
const person = personResults[activeIndex];
if (person) handleSelectPerson(person);
} else {
const movie = movieResults[activeIndex];
if (movie) handleSelectMovie(movie);
}
break;
case 'Escape':
@@ -178,7 +184,7 @@ export default function SearchAutocomplete({
};
return (
<div ref={containerRef} className="relative" role="combobox" aria-expanded={isOpen} aria-haspopup="listbox" aria-owns={listId}>
<div ref={containerRef} className="relative">
<div className="relative">
<Input
ref={inputRef}
@@ -192,9 +198,14 @@ export default function SearchAutocomplete({
setIsOpen(true);
}
}}
role="searchbox"
// ARIA 1.2 combobox: the role lives on the input, not a wrapper.
// The 1.1 wrapper pattern (role on a div, aria-owns) is what this
// was, and current screen readers announce it inconsistently.
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls={listId}
aria-controls={isOpen ? listId : undefined}
aria-activedescendant={
activeIndex >= 0 ? `${listId}-option-${activeIndex}` : undefined
}
@@ -0,0 +1,30 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import SerpentineConnector from './SerpentineConnector';
/**
* Purely decorative: the vertical line segment of the chain's connector. No
* accessible role applies, so the only observable contract is the inline
* position it's asked to render at.
*/
describe('SerpentineConnector', () => {
it('positions its vertical line at the given percentage', () => {
const { container } = render(<SerpentineConnector lineX={7.5} />);
const root = container.firstElementChild;
const line = root?.firstElementChild;
expect(line).not.toBeNull();
expect(line).toHaveStyle({ left: '7.5%' });
});
it('repositions when lineX changes', () => {
const { container, rerender } = render(<SerpentineConnector lineX={92.5} />);
expect(container.firstElementChild?.firstElementChild).toHaveStyle({
left: '92.5%',
});
rerender(<SerpentineConnector lineX={50} />);
expect(container.firstElementChild?.firstElementChild).toHaveStyle({
left: '50%',
});
});
});
@@ -0,0 +1,223 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChainLink, ScoreBreakdown } from '@/types';
import ShareableResult from './ShareableResult';
const SCORE: ScoreBreakdown = {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 125,
totalScore: 2400,
};
const CHAIN: ChainLink[] = [
{ type: 'movie', id: 100, title: 'Movie A', posterPath: null, releaseDate: '2000-01-01', popularity: 5 },
{ type: 'actor', id: 1, name: 'Actor One', profilePath: null, popularity: 5 },
{ type: 'movie', id: 200, title: 'Movie B', posterPath: null, releaseDate: '2001-01-01', popularity: 5 },
];
function baseProps() {
return {
score: SCORE,
chain: CHAIN,
movieATitle: 'Movie A',
movieBTitle: 'Movie B',
movieAReleaseDate: '2000-01-01',
movieBReleaseDate: '2001-01-01',
};
}
/** Lets a click handler's own promise chain (clipboard/share calls) settle. */
async function clickAndSettle(button: HTMLElement) {
await act(async () => {
fireEvent.click(button);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
});
afterEach(() => {
vi.useRealTimers();
Reflect.deleteProperty(navigator, 'clipboard');
Reflect.deleteProperty(navigator, 'share');
});
describe('ShareableResult', () => {
it('copies the generated share text to the clipboard', async () => {
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(writeText).toHaveBeenCalledTimes(1);
const text = writeText.mock.calls[0]?.[0] as string;
expect(text).toContain('Movie A (2000) <-> Movie B (2001)');
expect(text).toContain('Score: 2,400');
expect(text).toContain('Chain: 1 links');
expect(text).toContain('Time: 2:05');
});
it('confirms the copy, then reverts the label after two seconds', async () => {
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(
screen.getByRole('button', { name: /Copied!/ }),
).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(
screen.getByRole('button', { name: /Copy Result/ }),
).toBeInTheDocument();
});
it('labels the mode in the share text for a daily challenge and a versus match', async () => {
const { rerender } = render(
<ShareableResult {...baseProps()} mode={{ type: 'daily', date: '2024-01-01' }} />,
);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(writeText.mock.calls[0]?.[0]).toContain('Daily Challenge');
expect(writeText.mock.calls[0]?.[0]).toContain('/daily');
rerender(
<ShareableResult
{...baseProps()}
mode={{ type: 'versus', matchId: 'm1' }}
/>,
);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(writeText.mock.calls[1]?.[0]).toContain('Async Versus');
expect(writeText.mock.calls[1]?.[0]).toContain('/versus/m1');
});
it('links to plain versus play when no match id is given', async () => {
render(<ShareableResult {...baseProps()} mode={{ type: 'versus' }} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(writeText.mock.calls[0]?.[0]).toMatch(/\/versus$/);
});
it('omits a mode label and years the source omits', async () => {
render(
<ShareableResult
{...baseProps()}
movieAReleaseDate={null}
movieBReleaseDate={undefined}
mode={{ type: 'freeplay' }}
/>,
);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
const text = writeText.mock.calls[0]?.[0] as string;
expect(text).toContain('Movie A <-> Movie B');
expect(text).not.toMatch(/\u{1F3AF}/u);
});
it('does not crash when the clipboard API rejects', async () => {
writeText.mockRejectedValue(new Error('denied'));
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(
screen.getByRole('button', { name: /Copy Result/ }),
).toBeInTheDocument();
});
it('clears its pending reset timer on unmount, so it never sets state after unmounting', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { unmount } = render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
unmount();
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(errorSpy).not.toHaveBeenCalled();
errorSpy.mockRestore();
});
it('offers no Share button when the browser has no Web Share API', () => {
render(<ShareableResult {...baseProps()} />);
expect(screen.queryByRole('button', { name: /Share/ })).toBeNull();
});
it('shares via the Web Share API when available', async () => {
const share = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'share', { configurable: true, value: share });
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(screen.getByRole('button', { name: 'Share' }));
expect(share).toHaveBeenCalledTimes(1);
const arg = share.mock.calls[0]?.[0] as { text: string };
expect(arg.text).toContain('Score: 2,400');
expect(writeText).not.toHaveBeenCalled();
});
it('swallows a cancelled share without falling back to the clipboard', async () => {
const share = vi.fn().mockRejectedValue(new Error('cancelled'));
Object.defineProperty(navigator, 'share', { configurable: true, value: share });
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(screen.getByRole('button', { name: 'Share' }));
expect(share).toHaveBeenCalledTimes(1);
expect(writeText).not.toHaveBeenCalled();
});
it('falls back to copying when share becomes unavailable between render and click', async () => {
// Synthetic: the Share button's visibility and handleShare's own check
// both read `navigator.share` fresh, so this is the only way to exercise
// the handler's non-Web-Share fallback branch through the UI - the button
// is only rendered at all while `navigator.share` is a function.
let available = true;
const share = vi.fn();
Object.defineProperty(navigator, 'share', {
configurable: true,
get: () => (available ? share : undefined),
});
render(<ShareableResult {...baseProps()} />);
const shareButton = screen.getByRole('button', { name: 'Share' });
available = false;
await clickAndSettle(shareButton);
expect(share).not.toHaveBeenCalled();
expect(writeText).toHaveBeenCalledTimes(1);
});
});
+17 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import type { ScoreBreakdown, ChainLink } from '@/types';
import { getLinkCount } from '@/types';
@@ -20,7 +20,10 @@ interface ShareableResultProps {
mode?: GameMode;
}
const BASE_URL = 'https://movieloop.tehriehldeal.com';
// Whatever origin this build is actually being served from. Hardcoding the
// production host meant a staging or local build handed out production links.
const BASE_URL =
typeof window === 'undefined' ? '' : window.location.origin;
function getModeLabel(mode?: GameMode): string {
if (!mode) return '';
@@ -87,13 +90,24 @@ function generateShareText(props: ShareableResultProps): string {
export default function ShareableResult(props: ShareableResultProps) {
const [copied, setCopied] = useState(false);
const copyResetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// The reset timer outlived the component: copy, navigate away within two
// seconds, and setCopied fired on an unmounted component.
useEffect(
() => () => {
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
},
[],
);
const handleCopy = async () => {
const text = generateShareText(props);
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
copyResetTimer.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API not available
}
+48
View File
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import SoundToggle from './SoundToggle';
const KEY = 'movieloop-sound-enabled';
describe('SoundToggle', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('is on by default and names the control "Mute sounds"', () => {
render(<SoundToggle />);
expect(
screen.getByRole('button', { name: 'Mute sounds' }),
).toBeInTheDocument();
});
it('mutes on click and persists the choice', async () => {
const user = userEvent.setup();
render(<SoundToggle />);
await user.click(screen.getByRole('button', { name: 'Mute sounds' }));
expect(
screen.getByRole('button', { name: 'Enable sounds' }),
).toBeInTheDocument();
expect(localStorage.getItem(KEY)).toBe('false');
});
it('reflects a previously muted preference and re-enables on click', async () => {
localStorage.setItem(KEY, 'false');
const user = userEvent.setup();
render(<SoundToggle />);
const button = screen.getByRole('button', { name: 'Enable sounds' });
await user.click(button);
expect(
screen.getByRole('button', { name: 'Mute sounds' }),
).toBeInTheDocument();
expect(localStorage.getItem(KEY)).toBe('true');
});
});
-140
View File
@@ -1,140 +0,0 @@
import MovieCard from './MovieCard';
import ActorCard from './ActorCard';
import SerpentineConnector from './SerpentineConnector';
import { cn } from '@/lib/utils';
import type { ChainLink, ActorChainLink, MovieChainLink } from '@/types';
interface StaticChainDisplayProps {
chain: ChainLink[];
movieAId: number;
movieBId: number;
}
interface ChainPair {
actor: ActorChainLink;
movie?: MovieChainLink;
}
export default function StaticChainDisplay({
chain,
movieAId,
movieBId,
}: StaticChainDisplayProps) {
const pairs: ChainPair[] = [];
for (let i = 1; i < chain.length; i += 2) {
const actor = chain[i] as ActorChainLink;
const movie = chain[i + 1] as MovieChainLink | undefined;
pairs.push({ actor, movie });
}
const startMovie = chain[0] as MovieChainLink | undefined;
return (
<div>
{startMovie && (
<MovieCard movie={startMovie} isTarget="A" />
)}
{pairs.map((pair, pairIndex) => {
const isOdd = pairIndex % 2 === 0;
const isLastPair = pairIndex === pairs.length - 1;
let movieTarget: 'A' | 'B' | undefined;
if (pair.movie) {
if (pair.movie.id === movieBId) movieTarget = 'B';
else if (pair.movie.id === movieAId && isLastPair)
movieTarget = 'A';
}
const lineX = isOdd ? 7.5 : 92.5;
const cardEdge = isOdd ? 15 : 85;
const armLeft = Math.min(lineX, cardEdge);
const armRight = 100 - Math.max(lineX, cardEdge);
return (
<div key={pairIndex}>
<SerpentineConnector lineX={lineX} />
<div className={cn('relative flex', isOdd ? 'justify-end' : 'justify-start')}>
<div
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30"
style={{ left: `${lineX}%` }}
/>
<div
className="absolute top-1/2 h-0 border-t-2 border-muted-foreground/30"
style={{ left: `${armLeft}%`, right: `${armRight}%` }}
/>
<div
className={cn(
'flex w-[85%] items-center overflow-hidden rounded-lg border p-2.5',
movieTarget === 'B' && 'border-green-500 bg-green-500/5',
movieTarget === 'A' && 'border-primary bg-primary/5',
!movieTarget && 'border-border bg-card',
)}
>
{isOdd ? (
<>
<div className="flex min-w-0 flex-1 items-center justify-center">
<ActorCard actor={pair.actor} compact />
</div>
<Dot />
<div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie && (
<div className="flex items-center gap-1.5">
<MovieCard movie={pair.movie} compact />
{movieTarget && <TargetBadge target={movieTarget} />}
</div>
)}
</div>
</>
) : (
<>
<div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie && (
<div className="flex items-center gap-1.5">
{movieTarget && <TargetBadge target={movieTarget} />}
<MovieCard movie={pair.movie} compact />
</div>
)}
</div>
<Dot />
<div className="flex min-w-0 flex-1 items-center justify-center">
<ActorCard actor={pair.actor} compact />
</div>
</>
)}
</div>
</div>
</div>
);
})}
<div className="flex justify-center py-3">
<div className="rounded-full bg-green-500/10 px-4 py-1.5 text-sm font-medium text-green-600">
Loop Complete!
</div>
</div>
</div>
);
}
function Dot() {
return (
<div className="mx-2 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/40" />
);
}
function TargetBadge({ target }: { target: 'A' | 'B' }) {
return (
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
target === 'A'
? 'bg-blue-500/10 text-blue-600'
: 'bg-green-500/10 text-green-600',
)}
>
{target}
</span>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Timer is a thin view over useTimer/useGameStore: which color+label tier it
* picks for a given elapsed time, seeded through the real store per WP-19
* conventions rather than mocked.
*/
const { useGameStore } = await import('@/stores/game-store');
const { default: Timer } = await import('./Timer');
const NOW = new Date('2024-01-01T00:10:00.000Z').getTime();
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
useGameStore.getState().resetGame();
});
afterEach(() => {
vi.useRealTimers();
});
function seed(elapsedSeconds: number, status: 'playing' | 'idle' | 'completed' = 'playing') {
useGameStore.setState({ startTime: NOW - elapsedSeconds * 1000, status });
}
describe('Timer', () => {
it('shows the high bonus tier under a minute', () => {
seed(30);
render(<Timer />);
expect(screen.getByText('0:30')).toBeInTheDocument();
expect(screen.getByText('(+500)')).toBeInTheDocument();
});
it('shows the medium bonus tier between one and three minutes', () => {
seed(90);
render(<Timer />);
expect(screen.getByText('1:30')).toBeInTheDocument();
expect(screen.getByText('(+300)')).toBeInTheDocument();
});
it('shows the low bonus tier between three and five minutes', () => {
seed(200);
render(<Timer />);
expect(screen.getByText('3:20')).toBeInTheDocument();
expect(screen.getByText('(+100)')).toBeInTheDocument();
});
it('shows no bonus at five minutes or beyond', () => {
seed(301);
render(<Timer />);
expect(screen.getByText('5:01')).toBeInTheDocument();
expect(screen.getByText('(+0)')).toBeInTheDocument();
});
it('ticks upward every second while the game is playing', () => {
seed(0);
render(<Timer />);
expect(screen.getByText('0:00')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(3000);
});
expect(screen.getByText('0:03')).toBeInTheDocument();
});
it('reads 0:00 when no game has started', () => {
useGameStore.setState({ startTime: null, status: 'idle' });
render(<Timer />);
expect(screen.getByText('0:00')).toBeInTheDocument();
expect(screen.getByText('(+500)')).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MovieChainLink } from '@/types';
const { useGameStore } = await import('@/stores/game-store');
const { default: ValidationFeedback } = await import('./ValidationFeedback');
const movieA: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
beforeEach(() => {
vi.useFakeTimers();
useGameStore.getState().resetGame();
});
afterEach(() => {
vi.useRealTimers();
});
describe('ValidationFeedback', () => {
it('renders nothing in the default, settled state', () => {
const { container } = render(<ValidationFeedback />);
expect(container).toBeEmptyDOMElement();
});
it('announces validation in progress', () => {
useGameStore.setState({ isValidating: true });
render(<ValidationFeedback />);
expect(screen.getByRole('status')).toHaveTextContent(
'Validating connection...',
);
});
it('shows the error as an alert', () => {
useGameStore.setState({ validationError: 'That actor was not in that movie.' });
render(<ValidationFeedback />);
expect(screen.getByRole('alert')).toHaveTextContent(
'That actor was not in that movie.',
);
});
it('prioritises the validating state over a stale error', () => {
useGameStore.setState({ isValidating: true, validationError: 'stale' });
render(<ValidationFeedback />);
expect(screen.getByRole('status')).toHaveTextContent(
'Validating connection...',
);
expect(screen.queryByRole('alert')).toBeNull();
});
it('flashes a success check when the chain grows, then clears it', () => {
useGameStore.setState({ chain: [movieA] });
render(<ValidationFeedback />);
expect(screen.queryByText('Connected!')).toBeNull();
act(() => {
useGameStore.setState({ chain: [movieA, movieA] });
});
expect(screen.getByRole('status')).toHaveTextContent('Connected!');
act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.queryByText('Connected!')).toBeNull();
});
it('does not re-flash when the chain length is unchanged', () => {
useGameStore.setState({ chain: [movieA, movieA] });
const { rerender } = render(<ValidationFeedback />);
expect(screen.getByText('Connected!')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(600);
});
rerender(<ValidationFeedback />);
expect(screen.queryByText('Connected!')).toBeNull();
});
});
+13 -6
View File
@@ -25,8 +25,11 @@ export default function ValidationFeedback() {
if (isValidating) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<div
role="status"
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Validating connection...
</div>
);
@@ -36,9 +39,10 @@ export default function ValidationFeedback() {
return (
<div
key={validationError}
className="flex animate-shake items-center gap-2 text-sm text-destructive"
role="alert"
className="flex animate-shake items-center gap-2 text-sm text-destructive motion-reduce:animate-none"
>
<AlertCircle className="h-4 w-4" />
<AlertCircle className="h-4 w-4" aria-hidden="true" />
{validationError}
</div>
);
@@ -46,8 +50,11 @@ export default function ValidationFeedback() {
if (showCheck) {
return (
<div className="flex animate-check-flash items-center gap-2 text-sm text-green-600">
<CheckCircle2 className="h-4 w-4" />
<div
role="status"
className="flex animate-check-flash items-center gap-2 text-sm text-green-600 motion-reduce:animate-none"
>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
Connected!
</div>
);
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import HowToPlayDialog from './HowToPlayDialog';
describe('HowToPlayDialog', () => {
it('is closed until the help button is clicked', () => {
render(<HowToPlayDialog />);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('opens the how-to-play dialog from the help button', async () => {
render(<HowToPlayDialog />);
await userEvent.click(screen.getByRole('button', { name: 'How to play' }));
expect(
screen.getByRole('dialog', { name: 'How to Play' }),
).toBeInTheDocument();
expect(
screen.getByText(/Build a chain of actor-movie connections/),
).toBeInTheDocument();
});
it('describes the goal, connections, scoring, modes and tips', async () => {
render(<HowToPlayDialog />);
await userEvent.click(screen.getByRole('button', { name: 'How to play' }));
expect(screen.getByRole('heading', { name: 'The Goal' })).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'How Connections Work' }),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Scoring' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Game Modes' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Tips' })).toBeInTheDocument();
});
it('closes via its own close control', async () => {
render(<HowToPlayDialog />);
await userEvent.click(screen.getByRole('button', { name: 'How to play' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog')).toBeNull();
});
});
@@ -0,0 +1,399 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* The notification popover's accessibility contract. Before WP-16 it was a
* `<div>` with `<div onClick>` rows: nothing here was reachable or announced.
*/
const fetchNotifications = vi.fn();
const markRead = vi.fn();
const markAllRead = vi.fn();
const dismiss = vi.fn();
vi.mock('@/stores/notification-store', () => {
const state = {
notifications: [] as unknown[],
unreadCount: 0,
fetchNotifications: (...a: unknown[]) => fetchNotifications(...a),
markRead: (...a: unknown[]) => markRead(...a),
markAllRead: (...a: unknown[]) => markAllRead(...a),
dismiss: (...a: unknown[]) => dismiss(...a),
};
const useNotificationStore = (selector: (s: typeof state) => unknown) =>
selector(state);
useNotificationStore.setState = (patch: Partial<typeof state>) =>
Object.assign(state, patch);
return { useNotificationStore };
});
const { useNotificationStore } = await import('@/stores/notification-store');
const { default: NotificationBell } = await import('./NotificationBell');
type Store = { setState: (p: Record<string, unknown>) => void };
const store = useNotificationStore as unknown as Store;
const notification = (
id: string,
title: string,
read = false,
createdAt = new Date().toISOString(),
) => ({
id,
title,
message: `${title} body`,
read,
createdAt,
data: {},
});
function renderBell() {
return render(
<MemoryRouter>
<NotificationBell />
</MemoryRouter>,
);
}
function renderBellWithRoutes() {
return render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<NotificationBell />} />
<Route
path="/versus/async/:matchId/leaderboard"
element={<div>leaderboard route</div>}
/>
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
store.setState({ notifications: [], unreadCount: 0 });
});
describe('NotificationBell', () => {
it('names the trigger with the unread count and reports its popup state', async () => {
store.setState({ unreadCount: 3 });
renderBell();
const trigger = screen.getByRole('button', {
name: 'Notifications, 3 unread',
});
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
expect(trigger).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(fetchNotifications).toHaveBeenCalledTimes(1);
});
it('closes from a second click on the trigger without re-fetching', async () => {
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications' });
await userEvent.click(trigger);
expect(fetchNotifications).toHaveBeenCalledTimes(1);
await userEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('dialog')).toBeNull();
expect(fetchNotifications).toHaveBeenCalledTimes(1);
});
it('drops the count from the name when nothing is unread', () => {
renderBell();
expect(
screen.getByRole('button', { name: 'Notifications' }),
).toBeInTheDocument();
});
it('opens a labelled dialog that the trigger controls', async () => {
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications' });
await userEvent.click(trigger);
const dialog = screen.getByRole('dialog', { name: 'Notifications' });
expect(trigger).toHaveAttribute('aria-controls', dialog.id);
});
it('renders each notification as a keyboard-reachable button, with its actions beside it', async () => {
store.setState({
notifications: [
notification('n1', 'Alice challenged you'),
notification('n2', 'Bob accepted', true),
],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
const dialog = screen.getByRole('dialog');
const rows = within(dialog).getAllByRole('listitem');
expect(rows).toHaveLength(2);
const [row0, row1] = rows;
if (!row0 || !row1) throw new Error('expected two rows');
// The row action is a button, and it is not wrapping the other buttons.
// The name is the row's full content (title, body, time) - what a sighted
// user sees - prefixed by the visually hidden unread marker. Anchored, so
// it cannot match the "Mark ... as read" / "Dismiss ..." siblings.
const rowButton = within(row0).getByRole('button', {
name: /^Unread:\s*Alice challenged you/,
});
expect(rowButton.querySelector('button')).toBeNull();
// Per-row controls have real names, not just a tooltip.
expect(
within(row0).getByRole('button', {
name: 'Mark "Alice challenged you" as read',
}),
).toBeInTheDocument();
expect(
within(row0).getByRole('button', {
name: 'Dismiss "Alice challenged you"',
}),
).toBeInTheDocument();
// A read notification has no mark-read control and no "Unread" prefix.
expect(
within(row1).queryByRole('button', { name: /Mark .* as read/ }),
).toBeNull();
expect(
within(row1).getByRole('button', { name: /^Bob accepted/ }),
).toBeInTheDocument();
});
it('activates a row from the keyboard and marks it read', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you')],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
const row = screen.getByRole('button', {
name: /^Unread:\s*Alice challenged you/,
});
row.focus();
await userEvent.keyboard('{Enter}');
expect(markRead).toHaveBeenCalledWith('n1');
});
it('closes on Escape and returns focus to the trigger', async () => {
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications' });
await userEvent.click(trigger);
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Focus is somewhere inside the panel, as it would be after tabbing in.
screen.getByRole('button', { name: 'Close notifications' }).focus();
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).toBeNull();
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(trigger).toHaveFocus();
});
it('closes from its close button and returns focus to the trigger', async () => {
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications' });
await userEvent.click(trigger);
await userEvent.click(
screen.getByRole('button', { name: 'Close notifications' }),
);
expect(screen.queryByRole('dialog')).toBeNull();
expect(trigger).toHaveFocus();
});
it('exposes mark-all-read with a real name only while something is unread', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you')],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(
screen.getByRole('button', { name: 'Mark all notifications as read' }),
);
expect(markAllRead).toHaveBeenCalledTimes(1);
});
it('hides the decorative badge and icons from assistive tech', async () => {
store.setState({ unreadCount: 5 });
renderBell();
const trigger = screen.getByRole('button', { name: /Notifications/ });
// The visual badge duplicates what the name already says.
expect(trigger.querySelector('span[aria-hidden="true"]')).toHaveTextContent(
'5',
);
expect(trigger.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it('caps the visible badge count at 99+', () => {
store.setState({ unreadCount: 140 });
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications, 140 unread' });
expect(trigger.querySelector('span[aria-hidden="true"]')).toHaveTextContent('99+');
});
it('navigates to the match leaderboard and closes the panel for a versus notification', async () => {
store.setState({
notifications: [
{
...notification('n1', 'Your challenger has finished'),
data: { matchId: 'm42' },
},
],
unreadCount: 1,
});
renderBellWithRoutes();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(
screen.getByRole('button', { name: /^Unread:\s*Your challenger has finished/ }),
);
expect(screen.getByText('leaderboard route')).toBeInTheDocument();
expect(markRead).toHaveBeenCalledWith('n1');
});
it('marks a notification without a matchId read but does not navigate', async () => {
store.setState({
notifications: [notification('n1', 'Plain notice')],
unreadCount: 1,
});
renderBellWithRoutes();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(screen.getByRole('button', { name: /^Unread:\s*Plain notice/ }));
expect(markRead).toHaveBeenCalledWith('n1');
expect(screen.queryByText('leaderboard route')).toBeNull();
// Clicking a plain notification does not close the panel.
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('does not re-mark an already-read notification when clicked', async () => {
store.setState({
notifications: [notification('n1', 'Already read', true)],
unreadCount: 0,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(screen.getByRole('button', { name: /^Already read/ }));
expect(markRead).not.toHaveBeenCalled();
});
it('dismisses a notification from its trash control', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you')],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(
screen.getByRole('button', { name: 'Dismiss "Alice challenged you"' }),
);
expect(dismiss).toHaveBeenCalledWith('n1');
});
it('shows an empty state with no notifications', async () => {
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('No notifications')).toBeInTheDocument();
});
it('closes the panel when a click lands outside it', async () => {
render(
<MemoryRouter>
<div>
<NotificationBell />
<button type="button">outside</button>
</div>
</MemoryRouter>,
);
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'outside' }));
expect(screen.queryByRole('dialog')).toBeNull();
});
it('does not close when the click lands inside the panel', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you', true)],
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
const dialog = screen.getByRole('dialog');
await userEvent.click(dialog);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('marks a single notification read from its own control, without opening it', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you')],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(
screen.getByRole('button', { name: 'Mark "Alice challenged you" as read' }),
);
expect(markRead).toHaveBeenCalledWith('n1');
// Only the per-row control fired - not the row's own click handler too.
expect(markRead).toHaveBeenCalledTimes(1);
});
describe('timeAgo', () => {
it('reports minutes for something under an hour old', async () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
store.setState({
notifications: [notification('n1', 'Old one', true, fiveMinutesAgo)],
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('5m ago')).toBeInTheDocument();
});
it('reports hours for something under a day old', async () => {
const threeHoursAgo = new Date(Date.now() - 3 * 3_600_000).toISOString();
store.setState({
notifications: [notification('n1', 'Old one', true, threeHoursAgo)],
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('3h ago')).toBeInTheDocument();
});
it('reports days for anything older still', async () => {
const twoDaysAgo = new Date(Date.now() - 2 * 86_400_000).toISOString();
store.setState({
notifications: [notification('n1', 'Old one', true, twoDaysAgo)],
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('2d ago')).toBeInTheDocument();
});
});
});
+121 -57
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useNotificationStore } from '@/stores/notification-store';
import { Bell, Check, CheckCheck, Trash2, X } from 'lucide-react';
@@ -15,9 +15,21 @@ function timeAgo(dateStr: string) {
return `${days}d ago`;
}
/**
* The notification popover.
*
* This used to be a bare `<div>` with `<div onClick>` rows: no role, no
* `aria-expanded` on the trigger, nothing focusable, no Escape. A keyboard user
* could open it and then do nothing with it; a screen reader announced the
* bell and nothing about the count or the panel. It is now a non-modal dialog
* anchored to its trigger: the trigger reports state and count, Escape closes
* and returns focus, and each notification is a real button.
*/
export default function NotificationBell() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelId = useId();
const navigate = useNavigate();
const notifications = useNotificationStore((s) => s.notifications);
const unreadCount = useNotificationStore((s) => s.unreadCount);
@@ -26,17 +38,26 @@ export default function NotificationBell() {
const markAllRead = useNotificationStore((s) => s.markAllRead);
const dismiss = useNotificationStore((s) => s.dismiss);
// Close on outside click
// Close on outside click, and on Escape from anywhere inside.
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
const handleToggle = () => {
@@ -46,6 +67,11 @@ export default function NotificationBell() {
setOpen(!open);
};
const close = () => {
setOpen(false);
triggerRef.current?.focus();
};
const handleNotificationClick = (notification: (typeof notifications)[0]) => {
if (!notification.read) {
markRead(notification.id);
@@ -57,40 +83,62 @@ export default function NotificationBell() {
}
};
const triggerLabel =
unreadCount > 0
? `Notifications, ${unreadCount} unread`
: 'Notifications';
return (
<div ref={ref} className="relative">
<button
ref={triggerRef}
type="button"
onClick={handleToggle}
className="relative flex items-center justify-center rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Notifications"
aria-label={triggerLabel}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
>
<Bell className="h-4 w-4" />
<Bell className="h-4 w-4" aria-hidden="true" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white"
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border border-border bg-card shadow-lg">
<div
id={panelId}
role="dialog"
aria-label="Notifications"
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border border-border bg-card shadow-lg"
>
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-sm font-semibold">Notifications</span>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
type="button"
onClick={markAllRead}
className="rounded p-1 text-xs text-muted-foreground hover:text-foreground"
title="Mark all read"
aria-label="Mark all notifications as read"
>
<CheckCheck className="h-3.5 w-3.5" />
<CheckCheck className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
<button
onClick={() => setOpen(false)}
type="button"
onClick={close}
className="rounded p-1 text-muted-foreground hover:text-foreground"
aria-label="Close notifications"
>
<X className="h-3.5 w-3.5" />
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
@@ -101,55 +149,71 @@ export default function NotificationBell() {
No notifications
</p>
) : (
notifications.map((n) => (
<div
key={n.id}
onClick={() => handleNotificationClick(n)}
className={cn(
'flex cursor-pointer items-start gap-2 border-b border-border px-3 py-2.5 transition-colors last:border-0 hover:bg-muted/50',
!n.read && 'bg-primary/5',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{!n.read && (
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
)}
<p className="truncate text-sm font-medium">{n.title}</p>
</div>
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
{n.message}
</p>
<p className="mt-1 text-[10px] text-muted-foreground">
{timeAgo(n.createdAt)}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{!n.read && (
<button
onClick={(e) => {
e.stopPropagation();
markRead(n.id);
}}
className="rounded p-1 text-muted-foreground hover:text-foreground"
title="Mark as read"
>
<Check className="h-3 w-3" />
</button>
<ul className="m-0 list-none p-0">
{notifications.map((n) => (
<li
key={n.id}
className={cn(
'flex items-start gap-2 border-b border-border px-3 py-2.5 transition-colors last:border-0 hover:bg-muted/50',
!n.read && 'bg-primary/5',
)}
>
{/* The row's action is a button so it is reachable and
activatable from the keyboard. The per-row controls sit
beside it rather than inside it - a button inside a
button is invalid HTML and unreliable in every screen
reader. */}
<button
onClick={(e) => {
e.stopPropagation();
dismiss(n.id);
}}
className="rounded p-1 text-muted-foreground hover:text-destructive"
title="Dismiss"
type="button"
onClick={() => handleNotificationClick(n)}
className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 className="h-3 w-3" />
<span className="flex items-center gap-1.5">
{!n.read && (
<>
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 rounded-full bg-primary"
/>
<span className="sr-only">Unread: </span>
</>
)}
<span className="truncate text-sm font-medium">
{n.title}
</span>
</span>
<span className="mt-0.5 block text-xs text-muted-foreground line-clamp-2">
{n.message}
</span>
<span className="mt-1 block text-[10px] text-muted-foreground">
{timeAgo(n.createdAt)}
</span>
</button>
</div>
</div>
))
<div className="flex shrink-0 items-center gap-0.5">
{!n.read && (
<button
type="button"
onClick={() => markRead(n.id)}
className="rounded p-1 text-muted-foreground hover:text-foreground"
title="Mark as read"
aria-label={`Mark "${n.title}" as read`}
>
<Check className="h-3 w-3" aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={() => dismiss(n.id)}
className="rounded p-1 text-muted-foreground hover:text-destructive"
title="Dismiss"
aria-label={`Dismiss "${n.title}"`}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
+214
View File
@@ -0,0 +1,214 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { useFriendsStore } from '@/stores/friends-store';
import PageLayout from './PageLayout';
/**
* The shared header/shell. Seeded via `setState` on the real auth/friends
* stores per WP-19 convention, and rendered inside a `MemoryRouter` since the
* component reads `useLocation` and renders `Link`s directly (no `<Route>`
* matching is needed for that).
*/
function renderLayout(path = '/', noPadding = false) {
return render(
<MemoryRouter initialEntries={[path]}>
<PageLayout noPadding={noPadding}>
<p>page content</p>
</PageLayout>
</MemoryRouter>,
);
}
beforeEach(() => {
useAuthStore.setState({ user: null });
useFriendsStore.setState({ pendingCount: 0 });
});
describe('PageLayout - signed out', () => {
it('renders the page content and a Sign In link, with no account controls', () => {
renderLayout();
expect(screen.getByText('page content')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Sign In' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /Profile|Me/ })).toBeNull();
expect(screen.queryByRole('button', { name: /Notifications/ })).toBeNull();
expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull();
});
it('offers a skip link to the main content', () => {
renderLayout();
const skip = screen.getByRole('link', { name: 'Skip to content' });
expect(skip).toHaveAttribute('href', '#main-content');
});
it('highlights the nav link matching the current route', () => {
renderLayout('/daily');
expect(screen.getByRole('link', { name: 'Daily' }).className).toContain(
'text-foreground',
);
expect(screen.getByRole('link', { name: 'Home' }).className).toContain(
'text-muted-foreground',
);
});
it('treats any /friends/* route as the Friends link being active', () => {
renderLayout('/friends/requests');
expect(screen.getByRole('link', { name: 'Friends' }).className).toContain(
'text-foreground',
);
});
it('shows no pending badge when the count is zero', () => {
renderLayout();
const friendsLink = screen.getByRole('link', { name: 'Friends' });
expect(friendsLink.textContent).toBe('Friends');
});
it('shows the pending-request count next to Friends', () => {
useFriendsStore.setState({ pendingCount: 4 });
renderLayout();
const friendsLinks = screen.getAllByRole('link', { name: /Friends/ });
expect(friendsLinks[0]?.textContent).toContain('4');
});
it('opens the mobile menu from the hamburger and shows the same nav links', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog', { name: 'Menu' });
expect(dialog).toBeInTheDocument();
// No admin or sign-out controls without a signed-in user.
expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull();
expect(screen.queryByRole('button', { name: 'Sign Out' })).toBeNull();
});
it('closes the mobile menu when a nav link is clicked', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Two "Home" links exist (desktop + mobile) while the sheet is open; the
// one inside the dialog is the mobile one.
const dialog = screen.getByRole('dialog');
const homeLinkInDialog = screen.getAllByRole('link', { name: 'Home' })
.find((el) => dialog.contains(el));
expect(homeLinkInDialog).toBeDefined();
if (homeLinkInDialog) await userEvent.click(homeLinkInDialog);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('applies the fixed, no-scroll layout when noPadding is set', () => {
const { container } = renderLayout('/', true);
const root = container.firstElementChild;
expect(root?.className).toContain('h-[100dvh]');
expect(root?.className).not.toContain('min-h-screen');
});
it('applies the scrollable layout by default', () => {
const { container } = renderLayout('/', false);
const root = container.firstElementChild;
expect(root?.className).toContain('min-h-screen');
});
});
describe('PageLayout - signed in', () => {
beforeEach(() => {
useAuthStore.setState({
user: { id: 'u1', email: 'a@b.com', username: 'Riley', isAdmin: false },
});
});
it('shows the profile link and notification bell for a signed-in user', () => {
renderLayout();
expect(screen.getByRole('link', { name: /Riley/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Notifications/ })).toBeInTheDocument();
});
it('hides the admin link for a non-admin user', () => {
renderLayout();
expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull();
});
it('highlights the profile link while on the profile page', () => {
renderLayout('/profile');
expect(screen.getByRole('link', { name: /Riley/ }).className).toContain(
'text-foreground',
);
});
it('does not highlight the profile link elsewhere', () => {
renderLayout('/');
expect(screen.getByRole('link', { name: /Riley/ }).className).toContain(
'text-muted-foreground',
);
});
it('signs out from the desktop control, clearing the signed-in user', async () => {
renderLayout();
const signOutButtons = screen.getAllByRole('button');
const desktopSignOut = signOutButtons.find(
(b) => b.querySelector('.lucide-log-out') && b.className.includes('hidden'),
);
expect(desktopSignOut).toBeDefined();
if (desktopSignOut) await userEvent.click(desktopSignOut);
expect(useAuthStore.getState().user).toBeNull();
});
describe('admin user', () => {
beforeEach(() => {
useAuthStore.setState({
user: { id: 'u1', email: 'a@b.com', username: 'Riley', isAdmin: true },
});
});
it('shows the Admin link on the desktop nav', () => {
renderLayout('/admin');
const adminLink = screen.getAllByRole('link', { name: /Admin/ })[0];
expect(adminLink?.className).toContain('text-foreground');
});
it('shows the Admin link and a Sign Out button in the mobile menu too', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog');
const adminLinkInDialog = screen
.getAllByRole('link', { name: /Admin/ })
.find((el) => dialog.contains(el));
expect(adminLinkInDialog).toBeDefined();
expect(
screen.getAllByRole('button', { name: 'Sign Out' }).some((b) => dialog.contains(b)),
).toBe(true);
});
it('closes the sheet when the mobile Admin link is clicked', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog');
const adminLinkInDialog = screen
.getAllByRole('link', { name: /Admin/ })
.find((el) => dialog.contains(el));
expect(adminLinkInDialog).toBeDefined();
if (adminLinkInDialog) await userEvent.click(adminLinkInDialog);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('signs out and closes the sheet from the mobile control', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog');
const mobileSignOut = screen
.getAllByRole('button', { name: 'Sign Out' })
.find((b) => dialog.contains(b));
expect(mobileSignOut).toBeDefined();
if (mobileSignOut) await userEvent.click(mobileSignOut);
expect(useAuthStore.getState().user).toBeNull();
expect(screen.queryByRole('dialog')).toBeNull();
});
});
});
+13 -1
View File
@@ -42,6 +42,14 @@ export default function PageLayout({
"flex flex-col bg-gradient-to-b from-background to-background/95",
noPadding ? "h-[100dvh] overflow-hidden" : "min-h-screen",
)}>
{/* Visually hidden until focused, so keyboard and screen-reader users can
jump past the header on every page instead of tabbing through it. */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[60] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
Skip to content
</a>
<header
className={cn(
"border-b border-border bg-card/80 backdrop-blur-sm",
@@ -197,7 +205,11 @@ export default function PageLayout({
</div>
</div>
</header>
<main className={cn("mx-auto w-full max-w-4xl flex-1", noPadding ? "overflow-hidden" : "px-4 py-6")} role="main">
<main
id="main-content"
tabIndex={-1}
className={cn("mx-auto w-full max-w-4xl flex-1 outline-none", noPadding ? "overflow-hidden" : "px-4 py-6")}
>
{children}
</main>
</div>
@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import ThemeToggle from './ThemeToggle';
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove('dark');
});
afterEach(() => {
localStorage.clear();
document.documentElement.classList.remove('dark');
});
describe('ThemeToggle', () => {
it('defaults to dark mode when nothing is stored', () => {
render(<ThemeToggle />);
expect(
screen.getByRole('button', { name: 'Switch to light mode' }),
).toBeInTheDocument();
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('honours a stored light preference', () => {
localStorage.setItem('theme', 'light');
render(<ThemeToggle />);
expect(
screen.getByRole('button', { name: 'Switch to dark mode' }),
).toBeInTheDocument();
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
it('honours a stored dark preference', () => {
localStorage.setItem('theme', 'dark');
render(<ThemeToggle />);
expect(
screen.getByRole('button', { name: 'Switch to light mode' }),
).toBeInTheDocument();
});
it('falls back to dark for an unrecognised stored value', () => {
localStorage.setItem('theme', 'sepia');
render(<ThemeToggle />);
expect(
screen.getByRole('button', { name: 'Switch to light mode' }),
).toBeInTheDocument();
});
it('toggles from dark to light, persisting the choice and updating the document class', async () => {
render(<ThemeToggle />);
await userEvent.click(screen.getByRole('button', { name: 'Switch to light mode' }));
expect(
screen.getByRole('button', { name: 'Switch to dark mode' }),
).toBeInTheDocument();
expect(document.documentElement.classList.contains('dark')).toBe(false);
expect(localStorage.getItem('theme')).toBe('light');
});
it('toggles from light back to dark', async () => {
localStorage.setItem('theme', 'light');
render(<ThemeToggle />);
await userEvent.click(screen.getByRole('button', { name: 'Switch to dark mode' }));
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(localStorage.getItem('theme')).toBe('dark');
});
});
@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PushSubscriptionState } from '@/hooks/use-push-subscription';
/**
* The Profile page's push toggle: the view over usePushSubscription. The hook
* has its own tests; these cover what each hook state looks like to a user.
*/
const enable = vi.fn();
const disable = vi.fn();
let state: PushSubscriptionState;
vi.mock('@/hooks/use-push-subscription', () => ({
usePushSubscription: () => state,
}));
const { default: PushNotificationsSection } = await import(
'./PushNotificationsSection'
);
beforeEach(() => {
vi.clearAllMocks();
state = {
support: 'supported',
subscribed: false,
busy: false,
error: null,
enable,
disable,
};
});
describe('PushNotificationsSection', () => {
it('offers to turn push on, and calls enable', async () => {
render(<PushNotificationsSection />);
const button = screen.getByRole('button', {
name: 'Turn on push notifications',
});
expect(button).toHaveAttribute('aria-pressed', 'false');
await userEvent.click(button);
expect(enable).toHaveBeenCalledTimes(1);
expect(disable).not.toHaveBeenCalled();
});
it('offers to turn push off when subscribed, and calls disable', async () => {
state.subscribed = true;
render(<PushNotificationsSection />);
const button = screen.getByRole('button', {
name: 'Turn off push notifications',
});
expect(button).toHaveAttribute('aria-pressed', 'true');
await userEvent.click(button);
expect(disable).toHaveBeenCalledTimes(1);
});
it('is disabled while the initial check is running', () => {
state.subscribed = null;
render(<PushNotificationsSection />);
expect(screen.getByRole('button', { name: 'Checking…' })).toBeDisabled();
});
it('is disabled while an action is in flight', () => {
state.busy = true;
render(<PushNotificationsSection />);
expect(
screen.getByRole('button', { name: 'Turn on push notifications' }),
).toBeDisabled();
});
it('explains a blocked permission and disables the toggle', () => {
state.support = 'denied';
render(<PushNotificationsSection />);
expect(
screen.getByRole('button', { name: 'Turn on push notifications' }),
).toBeDisabled();
expect(
screen.getByText(/Notifications are blocked for this site/),
).toBeInTheDocument();
});
it('still lets a subscribed user turn push off after blocking it in the browser', () => {
state.support = 'denied';
state.subscribed = true;
render(<PushNotificationsSection />);
expect(
screen.getByRole('button', { name: 'Turn off push notifications' }),
).toBeEnabled();
expect(screen.queryByText(/blocked for this site/)).toBeNull();
});
it('shows the hook’s error', () => {
state.error = 'Push notifications are not configured on this server.';
render(<PushNotificationsSection />);
expect(screen.getByRole('alert')).toHaveTextContent(
'Push notifications are not configured on this server.',
);
});
it('says so, with no toggle, when the browser cannot do push', () => {
state.support = 'unsupported';
render(<PushNotificationsSection />);
expect(screen.queryByRole('button')).toBeNull();
expect(
screen.getByText('This browser does not support push notifications.'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { usePushSubscription } from '@/hooks/use-push-subscription';
import { Bell, BellOff, Loader2 } from 'lucide-react';
/**
* The opt-in for Web Push. Lives here rather than firing at sign-in because
* the browser only shows the permission prompt in response to a click, and
* asking on the first visit is how sites train people to say no.
*/
export default function PushNotificationsSection() {
const push = usePushSubscription();
if (push.support === 'unsupported') {
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold">Notifications</h3>
<p className="text-sm text-muted-foreground">
This browser does not support push notifications.
</p>
</div>
);
}
const checking = push.subscribed === null;
const blocked = push.support === 'denied';
return (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Notifications</h3>
<p className="text-sm text-muted-foreground">
Get a push notification when a friend challenges you or a versus
match finishes, even when the game is not open.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={checking || push.busy || (blocked && !push.subscribed)}
onClick={() => void (push.subscribed ? push.disable() : push.enable())}
aria-pressed={push.subscribed === true}
>
{push.busy || checking ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden="true" />
) : push.subscribed ? (
<BellOff className="mr-1.5 h-4 w-4" aria-hidden="true" />
) : (
<Bell className="mr-1.5 h-4 w-4" aria-hidden="true" />
)}
{checking
? 'Checking…'
: push.subscribed
? 'Turn off push notifications'
: 'Turn on push notifications'}
</Button>
{blocked && !push.subscribed && !push.error && (
<p className="text-sm text-muted-foreground">
Notifications are blocked for this site. Allow them in your browser
settings to turn push on.
</p>
)}
{push.error && (
<Alert variant="destructive">
<AlertDescription>{push.error}</AlertDescription>
</Alert>
)}
</div>
);
}
-5
View File
@@ -19,10 +19,6 @@ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
@@ -145,7 +141,6 @@ function DialogDescription({
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
-54
View File
@@ -1,54 +0,0 @@
"use client"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
-30
View File
@@ -15,10 +15,6 @@ function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
@@ -90,16 +86,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
@@ -110,26 +96,10 @@ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+346
View File
@@ -0,0 +1,346 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Alert, AlertAction, AlertDescription, AlertTitle } from './alert';
import { Badge } from './badge';
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from './card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './dialog';
import { DifficultyBadge } from './difficulty-badge';
import { DIFFICULTY_STYLES, getDifficultyButtonClass } from './difficulty-styles';
import { Input } from './input';
import { Separator } from './separator';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './sheet';
import { Skeleton } from './skeleton';
/**
* A single sweep over the small UI primitives in WP-19's group: each exported
* piece rendered with its variants, asserting on the DOM it produces rather
* than on implementation details of the underlying base-ui/cva plumbing.
*/
describe('Alert', () => {
it('renders as a labelled alert region with a title and description', () => {
render(
<Alert>
<AlertTitle>Heads up</AlertTitle>
<AlertDescription>Something happened.</AlertDescription>
</Alert>,
);
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Heads up');
expect(alert).toHaveTextContent('Something happened.');
});
it('applies the destructive variant class', () => {
render(<Alert variant="destructive">Danger</Alert>);
expect(screen.getByRole('alert').className).toContain('text-destructive');
});
it('defaults to the default variant', () => {
render(<Alert>Default</Alert>);
expect(screen.getByRole('alert').className).toContain('bg-card');
});
it('positions an action slot', () => {
render(
<Alert>
<AlertAction>
<button type="button">Undo</button>
</AlertAction>
</Alert>,
);
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
});
});
describe('Badge', () => {
it.each(['default', 'secondary', 'destructive', 'outline', 'ghost', 'link'] as const)(
'renders the %s variant as a span with its text',
(variant) => {
render(<Badge variant={variant}>Status</Badge>);
const badge = screen.getByText('Status');
expect(badge.tagName).toBe('SPAN');
expect(badge).toHaveAttribute('data-slot', 'badge');
},
);
it('defaults to the default variant when none is given', () => {
render(<Badge>Plain</Badge>);
expect(screen.getByText('Plain').className).toContain('bg-primary');
});
});
describe('Card', () => {
it('composes header, title, description, action, content and footer', () => {
render(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
<CardAction>
<button type="button">Action</button>
</CardAction>
</CardHeader>
<CardContent>Body</CardContent>
<CardFooter>Footer</CardFooter>
</Card>,
);
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
expect(screen.getByText('Body')).toBeInTheDocument();
expect(screen.getByText('Footer')).toBeInTheDocument();
});
it('applies the compact size variant', () => {
render(<Card size="sm" data-testid="card">content</Card>);
expect(screen.getByTestId('card')).toHaveAttribute('data-size', 'sm');
});
it('defaults to the default size', () => {
render(<Card data-testid="card">content</Card>);
expect(screen.getByTestId('card')).toHaveAttribute('data-size', 'default');
});
});
describe('Dialog', () => {
it('renders nothing while closed', () => {
render(
<Dialog open={false}>
<DialogContent>
<DialogHeader>
<DialogTitle>Hi</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>,
);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders a labelled dialog with a close button by default', async () => {
render(
<Dialog open>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>,
);
expect(screen.getByRole('dialog', { name: 'Settings' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
it('omits the close button when told to', () => {
render(
<Dialog open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>No close</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>,
);
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
});
it('opens, uncontrolled, from a DialogTrigger', async () => {
render(
<Dialog>
<DialogTrigger render={<button type="button">Open dialog</button>} />
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Triggered</DialogTitle>
<DialogDescription>Opened from its trigger.</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>,
);
expect(screen.queryByRole('dialog')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Open dialog' }));
expect(screen.getByRole('dialog', { name: 'Triggered' })).toBeInTheDocument();
expect(screen.getByText('Opened from its trigger.')).toBeInTheDocument();
});
it('renders footer content without an extra close button by default', () => {
render(
<Dialog open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Confirm</DialogTitle>
</DialogHeader>
<DialogFooter>
<button type="button">Save</button>
</DialogFooter>
</DialogContent>
</Dialog>,
);
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
});
it('adds a Close action to the footer when asked', () => {
render(
<Dialog open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Confirm</DialogTitle>
</DialogHeader>
<DialogFooter showCloseButton>
<button type="button">Save</button>
</DialogFooter>
</DialogContent>
</Dialog>,
);
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
});
describe('DifficultyBadge', () => {
it.each(['easy', 'medium', 'hard'] as const)(
'renders the %s difficulty with its own styling',
(difficulty) => {
render(<DifficultyBadge difficulty={difficulty} />);
const badge = screen.getByText(difficulty);
for (const cls of DIFFICULTY_STYLES[difficulty].split(' ')) {
expect(badge.className).toContain(cls);
}
},
);
it('falls back to the medium style for an unrecognised difficulty', () => {
render(<DifficultyBadge difficulty="expert" />);
const badge = screen.getByText('expert');
for (const cls of DIFFICULTY_STYLES.medium.split(' ')) {
expect(badge.className).toContain(cls);
}
});
it('merges a custom className', () => {
render(<DifficultyBadge difficulty="easy" className="my-extra-class" />);
expect(screen.getByText('easy').className).toContain('my-extra-class');
});
});
describe('getDifficultyButtonClass', () => {
it('is empty when inactive, regardless of difficulty', () => {
expect(getDifficultyButtonClass('easy', false)).toBe('');
expect(getDifficultyButtonClass('hard', false)).toBe('');
});
it.each(['easy', 'medium', 'hard'] as const)(
'returns the active styling for %s when active',
(difficulty) => {
const result = getDifficultyButtonClass(difficulty, true);
expect(result.length).toBeGreaterThan(0);
expect(result).toContain('border-');
},
);
});
describe('Input', () => {
it('renders a native input and accepts typed text', async () => {
render(<Input placeholder="Search…" />);
const input = screen.getByPlaceholderText('Search…');
expect(input.tagName).toBe('INPUT');
await userEvent.type(input, 'hello');
expect(input).toHaveValue('hello');
});
it('passes through the type attribute', () => {
render(<Input type="email" aria-label="Email" />);
expect(screen.getByLabelText('Email')).toHaveAttribute('type', 'email');
});
it('can be disabled', () => {
render(<Input aria-label="Disabled field" disabled />);
expect(screen.getByLabelText('Disabled field')).toBeDisabled();
});
});
describe('Separator', () => {
it('defaults to a horizontal orientation', () => {
render(<Separator data-testid="sep" />);
expect(screen.getByTestId('sep')).toHaveAttribute('data-orientation', 'horizontal');
});
it('honours a vertical orientation', () => {
render(<Separator orientation="vertical" data-testid="sep" />);
expect(screen.getByTestId('sep')).toHaveAttribute('data-orientation', 'vertical');
});
});
describe('Sheet', () => {
it('renders nothing while closed', () => {
render(
<Sheet open={false}>
<SheetContent>
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>,
);
expect(screen.queryByText('Menu')).toBeNull();
});
it('renders its content, side and close button when open', () => {
const onOpenChange = vi.fn();
render(
<Sheet open onOpenChange={onOpenChange}>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>,
);
expect(screen.getByText('Navigation')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
it('omits the close button when told to', () => {
render(
<Sheet open>
<SheetContent showCloseButton={false}>
<SheetHeader>
<SheetTitle>No close</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>,
);
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
});
});
describe('Skeleton', () => {
it('renders a placeholder block with the pulse animation class', () => {
render(<Skeleton data-testid="skeleton" />);
expect(screen.getByTestId('skeleton').className).toContain('animate-pulse');
});
it('merges a custom className', () => {
render(<Skeleton data-testid="skeleton" className="h-4 w-4" />);
expect(screen.getByTestId('skeleton').className).toContain('h-4');
});
});
@@ -0,0 +1,172 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { describe, expect, it } from 'vitest';
import AsyncCompletionModal from './AsyncCompletionModal';
import type { ComponentProps } from 'react';
import type { ScoreBreakdown } from '@/types';
const score = (totalScore: number): ScoreBreakdown => ({
baseScore: 0,
chainLength: 0,
linkCount: 0,
chainLengthBonus: 0,
timeBonus: 0,
obscurityBonus: 0,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 0,
totalScore,
});
function renderModal(props: Partial<ComponentProps<typeof AsyncCompletionModal>>) {
const defaults: ComponentProps<typeof AsyncCompletionModal> = {
open: true,
matchId: 'm1',
mode: 'creator',
myScore: score(1000),
myChainLength: 5,
};
return render(
<MemoryRouter initialEntries={['/versus/async/m1']}>
<Routes>
<Route
path="/versus/async/m1"
element={<AsyncCompletionModal {...defaults} {...props} />}
/>
<Route path="/versus" element={<div>versus route</div>} />
<Route
path="/versus/async/:matchId/leaderboard"
element={<div>leaderboard route</div>}
/>
</Routes>
</MemoryRouter>,
);
}
describe('AsyncCompletionModal', () => {
it('renders nothing when closed', () => {
renderModal({ open: false });
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders nothing without a score yet', () => {
renderModal({ myScore: null });
expect(screen.queryByRole('dialog')).toBeNull();
});
it('defaults myTotal to 0 when the score object has no totalScore field', () => {
renderModal({ mode: 'creator', myScore: {} as ScoreBreakdown });
expect(screen.getByText('0')).toBeInTheDocument();
});
describe('creator mode', () => {
it('shows the submitted score and link count', () => {
renderModal({ mode: 'creator', myScore: score(1234), myChainLength: 7 });
expect(screen.getByRole('heading', { name: /Score Submitted!/ })).toBeInTheDocument();
expect(screen.getByText('1234')).toBeInTheDocument();
expect(screen.getByText(/7 links/)).toBeInTheDocument();
});
it('navigates back to Versus', async () => {
renderModal({ mode: 'creator' });
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(screen.getByText('versus route')).toBeInTheDocument();
});
it('navigates to the match leaderboard', async () => {
renderModal({ mode: 'creator', matchId: 'm1' });
await userEvent.click(screen.getByRole('button', { name: 'View Leaderboard' }));
expect(screen.getByText('leaderboard route')).toBeInTheDocument();
});
});
describe('challenger mode', () => {
it('declares a tie when the scores match', () => {
renderModal({
mode: 'challenger',
myScore: score(1000),
creatorScore: { totalScore: 1000 },
});
expect(screen.getByText("It's a tie!")).toBeInTheDocument();
});
it('declares a win when the challenger scores higher', () => {
renderModal({
mode: 'challenger',
myScore: score(1500),
creatorScore: { totalScore: 1000 },
});
expect(screen.getByText('You beat the creator!')).toBeInTheDocument();
});
it('declares the creator the winner otherwise', () => {
renderModal({
mode: 'challenger',
myScore: score(500),
creatorScore: { totalScore: 1000 },
});
expect(screen.getByText('Creator wins!')).toBeInTheDocument();
});
it('defaults a missing creator score to 0, tying against a zero score', () => {
renderModal({ mode: 'challenger', myScore: score(0), creatorScore: null });
expect(screen.getByText("It's a tie!")).toBeInTheDocument();
const zeros = screen.getAllByText('0');
expect(zeros.length).toBeGreaterThanOrEqual(2);
});
it('beats a creator whose score defaults to 0', () => {
renderModal({ mode: 'challenger', myScore: score(50), creatorScore: null });
expect(screen.getByText('You beat the creator!')).toBeInTheDocument();
});
it('shows "?" for an unknown creator chain length', () => {
renderModal({ mode: 'challenger', creatorChainLength: null });
expect(screen.getByText('? links')).toBeInTheDocument();
});
it('shows the creator chain length when known', () => {
renderModal({ mode: 'challenger', creatorChainLength: 9 });
expect(screen.getByText('9 links')).toBeInTheDocument();
});
it('shows the rank only when one is given', () => {
const { rerender } = renderModal({ mode: 'challenger', rank: undefined });
expect(screen.queryByText(/Your rank/)).toBeNull();
rerender(
<MemoryRouter initialEntries={['/versus/async/m1']}>
<Routes>
<Route
path="/versus/async/m1"
element={
<AsyncCompletionModal
open
matchId="m1"
mode="challenger"
myScore={score(1000)}
myChainLength={5}
rank={3}
/>
}
/>
</Routes>
</MemoryRouter>,
);
expect(screen.getByText('#3')).toBeInTheDocument();
});
it('navigates back to Versus and to the full leaderboard', async () => {
renderModal({ mode: 'challenger' });
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(screen.getByText('versus route')).toBeInTheDocument();
});
it('links to the full leaderboard', async () => {
renderModal({ mode: 'challenger', matchId: 'm1' });
await userEvent.click(screen.getByRole('button', { name: 'Full Leaderboard' }));
expect(screen.getByText('leaderboard route')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { useVersusStore } from '@/stores/versus-store';
import OpponentProgress from './OpponentProgress';
const ME = { id: 'u1', username: 'Me' };
const RIVAL = { id: 'u2', username: 'Rival' };
beforeEach(() => {
useAuthStore.setState({ user: null });
useVersusStore.setState({
player1: null,
player2: null,
opponentChainLength: 0,
opponentFinished: false,
});
});
describe('OpponentProgress', () => {
it('renders nothing before an opponent has joined', () => {
const { container } = render(<OpponentProgress />);
expect(container).toBeEmptyDOMElement();
});
it('identifies the opponent as player2 when I am player1', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({ player1: ME, player2: RIVAL, opponentChainLength: 4 });
render(<OpponentProgress />);
expect(screen.getByText('Rival')).toBeInTheDocument();
expect(screen.getByText(/Chain: 1 links/)).toBeInTheDocument();
});
it('identifies the opponent as player1 when I am player2', () => {
useAuthStore.setState({ user: { id: 'u2', email: 'a@b.com', username: 'Rival', isAdmin: false } });
useVersusStore.setState({ player1: ME, player2: RIVAL, opponentChainLength: 2 });
render(<OpponentProgress />);
expect(screen.getByText('Me')).toBeInTheDocument();
});
it('shows the finished state and the hurry-up note once the opponent is done', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({ player1: ME, player2: RIVAL, opponentFinished: true });
render(<OpponentProgress />);
expect(screen.getByText('Finished!')).toBeInTheDocument();
expect(
screen.getByText('Your opponent has finished — hurry up!'),
).toBeInTheDocument();
expect(screen.queryByText(/Chain:/)).toBeNull();
});
});
@@ -0,0 +1,183 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { useVersusStore } from '@/stores/versus-store';
import VersusCompletionModal from './VersusCompletionModal';
import type { ScoreBreakdown } from '@/types';
/**
* The multiplayer completion modal. Seeded via `setState` on the real
* versus/auth stores rather than mocked, per WP-19 convention - the component
* reads several fields together and the derived `waitingForOpponent` /
* `matchResult` branches are the behaviour under test.
*/
const score = (totalScore: number): ScoreBreakdown => ({
baseScore: 0,
chainLength: 0,
linkCount: 0,
chainLengthBonus: 0,
timeBonus: 0,
obscurityBonus: 0,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 0,
totalScore,
});
function renderModal(initialEntry = '/versus/lobby/m1') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/versus/lobby/:matchId" element={<VersusCompletionModal />} />
<Route path="/versus" element={<div>versus route</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
useVersusStore.setState({
lobbyState: 'idle',
matchResult: null,
myScore: null,
});
useAuthStore.setState({ user: null });
});
describe('VersusCompletionModal', () => {
it('renders nothing before the local player has finished', () => {
renderModal();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('shows the waiting state once I finish but the opponent has not', () => {
useVersusStore.setState({ myScore: score(1500), lobbyState: 'playing' });
renderModal();
expect(screen.getByRole('dialog', { name: 'You finished!' })).toBeInTheDocument();
expect(screen.getByText('1500')).toBeInTheDocument();
expect(screen.getByText('Waiting for opponent to finish...')).toBeInTheDocument();
});
it('defaults myTotal to 0 when the score object has no totalScore field', () => {
useVersusStore.setState({
myScore: {} as ScoreBreakdown,
lobbyState: 'playing',
});
renderModal();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('lets a stuck player leave the waiting state, resetting and returning to Versus', async () => {
useVersusStore.setState({ myScore: score(1500), lobbyState: 'playing' });
renderModal();
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(useVersusStore.getState().myScore).toBeNull();
expect(screen.getByText('versus route')).toBeInTheDocument();
});
it('renders an empty dialog when finished with no match result yet', () => {
useVersusStore.setState({ myScore: score(1500), lobbyState: 'finished', matchResult: null });
renderModal();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.queryByText('Match Complete!')).toBeNull();
});
it('declares a tie when the match has no winner', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({
myScore: score(1500),
lobbyState: 'finished',
matchResult: {
winnerId: null,
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: score(1500),
player2Score: score(1500),
},
});
renderModal();
expect(screen.getByRole('heading', { name: /Match Complete!/ })).toBeInTheDocument();
expect(screen.getByText("It's a tie!")).toBeInTheDocument();
expect(screen.getByText('Me')).toBeInTheDocument();
expect(screen.getByText('Rival')).toBeInTheDocument();
});
it('declares a win for the signed-in player when they are the winner', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({
myScore: score(2000),
lobbyState: 'finished',
matchResult: {
winnerId: 'u1',
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: score(2000),
player2Score: score(900),
},
});
renderModal();
expect(screen.getByText('You win!')).toBeInTheDocument();
});
it('declares a loss when the winner is the other player', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({
myScore: score(900),
lobbyState: 'finished',
matchResult: {
winnerId: 'u2',
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: score(900),
player2Score: score(2000),
},
});
renderModal();
expect(screen.getByText('You lose!')).toBeInTheDocument();
});
it('defaults a missing player score to 0', () => {
useVersusStore.setState({
myScore: score(0),
lobbyState: 'finished',
matchResult: {
winnerId: null,
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: null,
player2Score: null,
},
});
renderModal();
const zeros = screen.getAllByText('0');
expect(zeros.length).toBeGreaterThanOrEqual(2);
});
it('returns to Versus from the finished result', async () => {
useVersusStore.setState({
myScore: score(1500),
lobbyState: 'finished',
matchResult: {
winnerId: null,
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: score(1500),
player2Score: score(1500),
},
});
renderModal();
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(screen.getByText('versus route')).toBeInTheDocument();
});
});
+433
View File
@@ -0,0 +1,433 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
ActorChainLink,
MovieChainLink,
TmdbMovieResult,
TmdbPersonResult,
} from '@/types';
/**
* The chain-validation state machine. Two mirror-image paths: with a server
* session every link is validated by the backend (the authoritative path since
* WP-3); without one - anonymous play with the backend unreachable - the hook
* falls back to checking TMDB credits client-side.
*
* Requests are intercepted at the axios client and routed by URL, the same seam
* `game-store.test.ts` uses and for the same reason: it is the one import that
* vitest mocks reliably, and nothing can reach the network.
*/
const validateLink = vi.fn();
const movieCredits = vi.fn();
const personCredits = vi.fn();
const playSound = vi.fn();
const toastWarning = vi.fn();
type Handler = (body: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'post' && (m = /^\/games\/([^/]+)\/validate-link$/.exec(url))) {
return (body) => validateLink(m![1], body);
}
if (method === 'get' && (m = /^\/movies\/(\d+)\/credits$/.exec(url))) {
return () => movieCredits(Number(m![1]));
}
if (method === 'get' && (m = /^\/persons\/(\d+)\/movie-credits$/.exec(url))) {
return () => personCredits(Number(m![1]));
}
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string, body?: unknown): Promise<{ data: unknown }> => ({
data: await route(method, url)(body),
});
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
vi.mock('@/lib/sounds', () => ({
playSound: (...args: unknown[]) => playSound(...args),
}));
vi.mock('sonner', () => ({
toast: { warning: (...args: unknown[]) => toastWarning(...args) },
}));
const { useGameStore } = await import('@/stores/game-store');
const { useChainValidation } = await import('./use-chain-validation');
// --- Fixtures ---------------------------------------------------------------
const movieLink = (id: number, title = `Movie ${id}`): MovieChainLink => ({
type: 'movie',
id,
title,
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
});
const actorLink = (id: number, name = `Actor ${id}`): ActorChainLink => ({
type: 'actor',
id,
name,
profilePath: null,
popularity: 5,
});
const person = (id: number, name = `Actor ${id}`): TmdbPersonResult => ({
id,
name,
profile_path: null,
popularity: 7,
known_for_department: 'Acting',
known_for: [],
adult: false,
});
const tmdbMovie = (id: number, title = `Movie ${id}`): TmdbMovieResult => ({
id,
title,
original_title: title,
overview: '',
poster_path: '/p.jpg',
backdrop_path: null,
release_date: '2001-02-03',
popularity: 9,
vote_average: 7,
vote_count: 100,
genre_ids: [],
adult: false,
});
/** An axios-shaped rejection carrying a server message. */
const serverError = (message: string | string[]) => ({
response: { data: { message } },
});
const MOVIE_A = movieLink(100, 'Movie A');
const MOVIE_B = movieLink(200, 'Movie B');
function playing(
chain: (MovieChainLink | ActorChainLink)[],
extra: Partial<ReturnType<typeof useGameStore.getState>> = {},
) {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain,
passedMovieB: chain.some((l) => l.type === 'movie' && l.id === MOVIE_B.id),
validationError: null,
isValidating: false,
...extra,
});
}
beforeEach(() => {
vi.clearAllMocks();
useGameStore.getState().resetGame();
validateLink.mockResolvedValue({ valid: true });
movieCredits.mockResolvedValue({ id: 0, cast: [] });
personCredits.mockResolvedValue({ id: 0, cast: [] });
});
/**
* Rendered outside `act` on purpose: calling renderHook inside act defers the
* render until act completes, so `result.current` is still null when read.
*/
function hook() {
return renderHook(() => useChainValidation()).result.current;
}
// --- Actors -----------------------------------------------------------------
describe('validateAndAddActor', () => {
it('refuses an actor unless the last link is a movie', async () => {
playing([MOVIE_A, actorLink(1)]);
const h = hook();
await act(() => h.validateAndAddActor(person(2)));
expect(useGameStore.getState().validationError).toMatch(/Expected a movie/);
expect(playSound).toHaveBeenCalledWith('invalid');
expect(validateLink).not.toHaveBeenCalled();
expect(useGameStore.getState().chain).toHaveLength(2);
});
it('warns about a duplicate actor but still proceeds - reuse is penalised, not blocked', async () => {
playing([MOVIE_A, actorLink(7, 'Repeat'), movieLink(300)], {
sessionId: 's1',
});
const h = hook();
await act(() => h.validateAndAddActor(person(7, 'Repeat')));
expect(toastWarning).toHaveBeenCalledWith(
'Repeat is already in your chain',
expect.objectContaining({ description: expect.stringMatching(/points/) }),
);
expect(validateLink).toHaveBeenCalled();
expect(useGameStore.getState().chain).toHaveLength(4);
});
describe('with a server session', () => {
it('sends the link to the server and adds it on success', async () => {
playing([MOVIE_A], { sessionId: 's1' });
const h = hook();
await act(() => h.validateAndAddActor(person(7, 'Seven')));
expect(validateLink).toHaveBeenCalledWith('s1', {
type: 'actor',
id: 7,
name: 'Seven',
popularity: 7,
});
const state = useGameStore.getState();
expect(state.chain[1]).toMatchObject({ type: 'actor', id: 7, name: 'Seven' });
expect(state.currentSearchMode).toBe('movie');
expect(state.validationError).toBeNull();
expect(state.isValidating).toBe(false);
expect(playSound).toHaveBeenCalledWith('valid');
// The client-side fallback must not run when the server is in charge.
expect(movieCredits).not.toHaveBeenCalled();
});
it('forwards a profile path when the person has one', async () => {
playing([MOVIE_A], { sessionId: 's1' });
const h = hook();
await act(() =>
h.validateAndAddActor({ ...person(7), profile_path: '/x.jpg' }),
);
expect(validateLink.mock.calls[0]?.[1]).toMatchObject({
profilePath: '/x.jpg',
});
});
it('shows the server rejection and leaves the chain untouched', async () => {
playing([MOVIE_A], { sessionId: 's1' });
validateLink.mockRejectedValue(
serverError('Seven was not in "Movie A".'),
);
const h = hook();
await act(() => h.validateAndAddActor(person(7, 'Seven')));
const state = useGameStore.getState();
expect(state.validationError).toBe('Seven was not in "Movie A".');
expect(state.chain).toHaveLength(1);
expect(state.isValidating).toBe(false);
expect(playSound).toHaveBeenCalledWith('invalid');
});
it('takes the first message when the server returns a list', async () => {
playing([MOVIE_A], { sessionId: 's1' });
validateLink.mockRejectedValue(serverError(['first problem', 'second']));
const h = hook();
await act(() => h.validateAndAddActor(person(7)));
expect(useGameStore.getState().validationError).toBe('first problem');
});
it('explains a network failure specifically', async () => {
playing([MOVIE_A], { sessionId: 's1' });
validateLink.mockRejectedValue(new Error('Network Error'));
const h = hook();
await act(() => h.validateAndAddActor(person(7)));
expect(useGameStore.getState().validationError).toMatch(
/check your connection/,
);
});
it('falls back to a generic message for anything else', async () => {
playing([MOVIE_A], { sessionId: 's1' });
validateLink.mockRejectedValue(new Error('something odd'));
const h = hook();
await act(() => h.validateAndAddActor(person(7)));
expect(useGameStore.getState().validationError).toBe(
'Failed to validate. Please try again.',
);
});
it('marks validating while the request is in flight', async () => {
playing([MOVIE_A], { sessionId: 's1' });
let release: (v: unknown) => void = () => undefined;
validateLink.mockImplementation(
() => new Promise((resolve) => (release = resolve)),
);
const h = hook();
let pending: Promise<void> = Promise.resolve();
act(() => {
pending = h.validateAndAddActor(person(7));
});
expect(useGameStore.getState().isValidating).toBe(true);
release({ valid: true });
await act(() => pending);
expect(useGameStore.getState().isValidating).toBe(false);
});
});
describe('without a session (client-side fallback)', () => {
it('checks the last movie credits and adds the actor when present', async () => {
playing([MOVIE_A, actorLink(1), movieLink(300, 'Third')]);
movieCredits.mockResolvedValue({ id: 300, cast: [{ id: 7 }] });
const h = hook();
await act(() => h.validateAndAddActor(person(7)));
expect(movieCredits).toHaveBeenCalledWith(300);
expect(validateLink).not.toHaveBeenCalled();
expect(useGameStore.getState().chain).toHaveLength(4);
expect(playSound).toHaveBeenCalledWith('valid');
});
it('rejects an actor who is not in that movie', async () => {
playing([MOVIE_A]);
movieCredits.mockResolvedValue({ id: 100, cast: [{ id: 8 }] });
const h = hook();
await act(() => h.validateAndAddActor(person(7, 'Seven')));
const state = useGameStore.getState();
expect(state.validationError).toBe('Seven was not in "Movie A".');
expect(state.chain).toHaveLength(1);
expect(state.isValidating).toBe(false);
expect(playSound).toHaveBeenCalledWith('invalid');
});
});
});
// --- Movies -----------------------------------------------------------------
describe('validateAndAddMovie', () => {
it('refuses a movie unless the last link is an actor', async () => {
playing([MOVIE_A]);
const h = hook();
await act(() => h.validateAndAddMovie(tmdbMovie(300)));
expect(useGameStore.getState().validationError).toMatch(/Expected an actor/);
expect(validateLink).not.toHaveBeenCalled();
});
it('refuses a return to movie A before movie B has been visited, without a request', async () => {
playing([MOVIE_A, actorLink(1)], { sessionId: 's1' });
const h = hook();
await act(() => h.validateAndAddMovie(tmdbMovie(100, 'Movie A')));
expect(useGameStore.getState().validationError).toBe(
'You need to pass through "Movie B" before returning to "Movie A".',
);
expect(validateLink).not.toHaveBeenCalled();
expect(playSound).toHaveBeenCalledWith('invalid');
});
it('warns about a duplicate movie but still proceeds', async () => {
playing([MOVIE_A, actorLink(1), movieLink(300, 'Again'), actorLink(2)], {
sessionId: 's1',
});
const h = hook();
await act(() => h.validateAndAddMovie(tmdbMovie(300, 'Again')));
expect(toastWarning).toHaveBeenCalledWith(
'"Again" is already in your chain',
expect.anything(),
);
expect(useGameStore.getState().chain).toHaveLength(5);
});
it('does not treat the closing return to movie A as a duplicate', async () => {
playing([MOVIE_A, actorLink(1), MOVIE_B, actorLink(2)], { sessionId: 's1' });
const h = hook();
await act(() => h.validateAndAddMovie(tmdbMovie(100, 'Movie A')));
expect(toastWarning).not.toHaveBeenCalled();
});
describe('with a server session', () => {
it('sends the link to the server and adds it on success', async () => {
playing([MOVIE_A, actorLink(1)], { sessionId: 's1' });
const h = hook();
await act(() => h.validateAndAddMovie(tmdbMovie(300, 'Third')));
expect(validateLink).toHaveBeenCalledWith('s1', {
type: 'movie',
id: 300,
title: 'Third',
posterPath: '/p.jpg',
releaseDate: '2001-02-03',
popularity: 9,
});
const state = useGameStore.getState();
expect(state.chain[2]).toMatchObject({ type: 'movie', id: 300 });
expect(state.currentSearchMode).toBe('actor');
expect(personCredits).not.toHaveBeenCalled();
});
it('normalises a missing poster path to undefined rather than sending null', async () => {
playing([MOVIE_A, actorLink(1)], { sessionId: 's1' });
const h = hook();
await act(() =>
h.validateAndAddMovie({ ...tmdbMovie(300), poster_path: null }),
);
// The schema accepts null and normalises it, but the hook does not lean on
// that: it sends undefined, which axios drops from the JSON body.
const body = validateLink.mock.calls[0]?.[1] as { posterPath?: unknown };
expect(body.posterPath).toBeUndefined();
});
it('shows the server rejection and leaves the chain untouched', async () => {
playing([MOVIE_A, actorLink(1)], { sessionId: 's1' });
validateLink.mockRejectedValue(serverError('Actor 1 was not in "Third".'));
const h = hook();
await act(() => h.validateAndAddMovie(tmdbMovie(300, 'Third')));
expect(useGameStore.getState().validationError).toBe(
'Actor 1 was not in "Third".',
);
expect(useGameStore.getState().chain).toHaveLength(2);
});
});
describe('without a session (client-side fallback)', () => {
it('checks the actor filmography and adds the movie when present', async () => {
playing([MOVIE_A, actorLink(1)]);
personCredits.mockResolvedValue({ id: 1, cast: [{ id: 300 }] });
const h = hook();
await act(() => h.validateAndAddMovie(tmdbMovie(300)));
expect(personCredits).toHaveBeenCalledWith(1);
expect(useGameStore.getState().chain).toHaveLength(3);
});
it('rejects a movie the actor was not in', async () => {
playing([MOVIE_A, actorLink(1, 'One')]);
personCredits.mockResolvedValue({ id: 1, cast: [{ id: 301 }] });
const h = hook();
await act(() => h.validateAndAddMovie(tmdbMovie(300, 'Third')));
expect(useGameStore.getState().validationError).toBe(
'One was not in "Third".',
);
expect(useGameStore.getState().chain).toHaveLength(2);
expect(useGameStore.getState().isValidating).toBe(false);
});
it('surfaces a credits lookup failure', async () => {
playing([MOVIE_A, actorLink(1)]);
personCredits.mockRejectedValue(new Error('Network Error'));
const h = hook();
await act(() => h.validateAndAddMovie(tmdbMovie(300)));
expect(useGameStore.getState().validationError).toMatch(/connection/);
});
});
});
+3 -19
View File
@@ -5,6 +5,7 @@ import { validateLink } from '@/api/games';
import { getMovieCredits } from '@/api/movies';
import { getPersonMovieCredits } from '@/api/persons';
import { playSound } from '@/lib/sounds';
import { getErrorMessage } from '@/lib/error';
import type {
ActorChainLink,
MovieChainLink,
@@ -82,7 +83,7 @@ export function useChainValidation() {
playSound('valid');
} catch (error: unknown) {
const message = extractErrorMessage(error, 'Failed to validate. Please try again.');
const message = getErrorMessage(error, 'Failed to validate. Please try again.');
setValidationError(message);
playSound('invalid');
} finally {
@@ -163,7 +164,7 @@ export function useChainValidation() {
playSound('valid');
} catch (error: unknown) {
const message = extractErrorMessage(error, 'Failed to validate. Please try again.');
const message = getErrorMessage(error, 'Failed to validate. Please try again.');
setValidationError(message);
playSound('invalid');
} finally {
@@ -175,20 +176,3 @@ export function useChainValidation() {
return { validateAndAddActor, validateAndAddMovie };
}
/** Extract a human-readable error message from an Axios error or generic error. */
function extractErrorMessage(error: unknown, fallback: string): string {
if (error && typeof error === 'object' && 'response' in error) {
const resp = (error as { response?: { data?: { message?: string | string[] } } })
.response;
if (resp?.data?.message) {
return typeof resp.data.message === 'string'
? resp.data.message
: resp.data.message[0];
}
}
if (error instanceof Error && error.message === 'Network Error') {
return 'Network error \u2014 check your connection and try again.';
}
return fallback;
}
+89
View File
@@ -0,0 +1,89 @@
import { renderHook, act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDebounce } from './use-debounce';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('useDebounce', () => {
it('starts with the initial value', () => {
const { result } = renderHook(() => useDebounce('a'));
expect(result.current).toBe('a');
});
it('does not update before the delay elapses', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'a' },
});
rerender({ value: 'b' });
act(() => {
vi.advanceTimersByTime(299);
});
expect(result.current).toBe('a');
});
it('updates to the latest value once the default 300ms delay elapses', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'a' },
});
rerender({ value: 'b' });
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('b');
});
it('honours a custom delay', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 1000),
{ initialProps: { value: 'a' } },
);
rerender({ value: 'b' });
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe('a');
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe('b');
});
it('resets the timer when the value changes again before it fires', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'a' },
});
rerender({ value: 'b' });
act(() => {
vi.advanceTimersByTime(200);
});
rerender({ value: 'c' });
act(() => {
vi.advanceTimersByTime(200);
});
// 400ms since 'b' arrived, but only 200ms since 'c' - not yet due.
expect(result.current).toBe('a');
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('c');
});
it('clears the pending timer on unmount', () => {
const { rerender, unmount } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'a' },
});
rerender({ value: 'b' });
unmount();
// Nothing to assert on `result` after unmount; this just proves the
// cleanup runs without throwing when timers later fire.
act(() => {
vi.advanceTimersByTime(1000);
});
});
});
+168
View File
@@ -0,0 +1,168 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useGameStore } from '@/stores/game-store';
import { useGameScreen } from './use-game-screen';
import type { ActorChainLink, MovieChainLink } from '@/types';
const movieLink = (id: number, title = `Movie ${id}`): MovieChainLink => ({
type: 'movie',
id,
title,
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
});
const actorLink = (id: number, name = `Actor ${id}`): ActorChainLink => ({
type: 'actor',
id,
name,
profilePath: null,
popularity: 5,
});
const MOVIE_A = movieLink(100, 'Movie A');
const MOVIE_B = movieLink(200, 'Movie B');
beforeEach(() => {
useGameStore.getState().resetGame();
});
describe('useGameScreen', () => {
it('is not ready while the game is idle', () => {
const { result } = renderHook(() => useGameScreen());
expect(result.current.ready).toBe(false);
expect(result.current.status).toBe('idle');
});
it('is not ready when either target movie is missing, even mid-game', () => {
useGameStore.setState({ status: 'playing', movieA: MOVIE_A, movieB: null });
const { result } = renderHook(() => useGameScreen());
expect(result.current.ready).toBe(false);
});
it('is ready with both movies once the game is playing', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.ready).toBe(true);
if (result.current.ready) {
expect(result.current.movieA).toBe(MOVIE_A);
expect(result.current.movieB).toBe(MOVIE_B);
}
});
it('stays ready once the game is completed', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A, actorLink(1), MOVIE_B, actorLink(2), MOVIE_A],
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.ready).toBe(true);
});
it('prompts for an actor after the last link is a movie', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
currentSearchMode: 'actor',
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('Who was in "Movie A"?');
expect(result.current.searchPlaceholder).toBe('Search for an actor...');
});
it('prompts for a movie after the last link is an actor', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A, actorLink(1, 'Keanu')],
currentSearchMode: 'movie',
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('What movie was Keanu also in?');
expect(result.current.searchPlaceholder).toBe('Search for a movie...');
});
it('has no label when the chain is empty', () => {
const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('');
expect(result.current.searchPlaceholder).toBe('');
});
it('has no label when the search mode and last link type are mismatched mid-transition', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1)],
currentSearchMode: 'actor',
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('');
});
it('flags the loop hint once movie B has been passed and a movie is next', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A, actorLink(1), MOVIE_B, actorLink(2)],
currentSearchMode: 'movie',
passedMovieB: true,
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.showLoopHint).toBe(true);
});
it('does not flag the loop hint before movie B has been passed', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
currentSearchMode: 'movie',
passedMovieB: false,
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.showLoopHint).toBe(false);
});
it('does not flag the loop hint while searching for an actor', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A, actorLink(1), MOVIE_B],
currentSearchMode: 'actor',
passedMovieB: true,
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.showLoopHint).toBe(false);
});
it('does not flag the loop hint without a movie A', () => {
useGameStore.setState({
status: 'idle',
movieA: null,
passedMovieB: true,
currentSearchMode: 'movie',
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.showLoopHint).toBe(false);
});
it('exposes the store actions and validating flag directly', () => {
useGameStore.setState({ isValidating: true });
const { result } = renderHook(() => useGameScreen());
expect(result.current.isValidating).toBe(true);
expect(result.current.undoLastLink).toBe(useGameStore.getState().undoLastLink);
expect(typeof result.current.validateAndAddActor).toBe('function');
expect(typeof result.current.validateAndAddMovie).toBe('function');
});
});
+90
View File
@@ -0,0 +1,90 @@
import { useGameStore } from '@/stores/game-store';
import { useChainValidation } from '@/hooks/use-chain-validation';
import type {
ChainLink,
GameStatus,
MovieChainLink,
SearchMode,
TmdbMovieResult,
TmdbPersonResult,
} from '@/types';
/**
* Everything the four game screens (solo, versus, game night, async versus)
* derive identically from the game store: the store fields they all read, the
* validation callbacks, and the copy that depends on where the chain stands.
*
* Each screen still owns what makes it that screen - its idle redirect, its
* multiplayer broadcasts, its layout - and only reads shared state from here.
*/
interface GameScreenState {
status: GameStatus;
chain: ChainLink[];
currentSearchMode: SearchMode;
passedMovieB: boolean;
isValidating: boolean;
undoLastLink: () => void;
validateAndAddActor: (person: TmdbPersonResult) => Promise<void>;
validateAndAddMovie: (movie: TmdbMovieResult) => Promise<void>;
/** Prompt for the next link, e.g. `Who was in "Speed"?`; '' when the chain is mid-transition. */
searchLabel: string;
searchPlaceholder: string;
/** Movie B is behind the player and the next movie can close the loop. */
showLoopHint: boolean;
}
export type GameScreen =
/** Nothing to show: no game is in progress. Screens render null. */
| (GameScreenState & { ready: false })
/** A game is in progress or complete, with both target movies known. */
| (GameScreenState & { ready: true; movieA: MovieChainLink; movieB: MovieChainLink });
export type ReadyGameScreen = Extract<GameScreen, { ready: true }>;
export function useGameScreen(): GameScreen {
const status = useGameStore((s) => s.status);
const chain = useGameStore((s) => s.chain);
const currentSearchMode = useGameStore((s) => s.currentSearchMode);
const passedMovieB = useGameStore((s) => s.passedMovieB);
const movieA = useGameStore((s) => s.movieA);
const movieB = useGameStore((s) => s.movieB);
const isValidating = useGameStore((s) => s.isValidating);
const undoLastLink = useGameStore((s) => s.undoLastLink);
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
// Build contextual search label
const lastLink = chain[chain.length - 1];
let searchLabel = '';
let searchPlaceholder = '';
if (currentSearchMode === 'actor' && lastLink?.type === 'movie') {
searchLabel = `Who was in "${lastLink.title}"?`;
searchPlaceholder = 'Search for an actor...';
} else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') {
searchLabel = `What movie was ${lastLink.name} also in?`;
searchPlaceholder = 'Search for a movie...';
}
// Hint for closing the loop
const showLoopHint =
passedMovieB && currentSearchMode === 'movie' && movieA !== null;
const state: GameScreenState = {
status,
chain,
currentSearchMode,
passedMovieB,
isValidating,
undoLastLink,
validateAndAddActor,
validateAndAddMovie,
searchLabel,
searchPlaceholder,
showLoopHint,
};
if (status === 'idle' || !movieA || !movieB) {
return { ...state, ready: false };
}
return { ...state, ready: true, movieA, movieB };
}
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more