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
No files matched your search
@@ -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.
|
||||
|
||||
@@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Test output
|
||||
coverage
|
||||
.vitest
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 +0,0 @@
|
||||
window.__APP_CONFIG__ = { apiUrl: "${API_URL}" };
|
||||
@@ -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,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." />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 867 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 +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 |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 +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();
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 "{movieA.title}" to close the loop!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||