PHASE4_TEST_MATRIX.md 13 KB

Phase 4 (Randomizer) — Test Matrix

Owner: Phase 4 U11 (final a11y sweep + grep audit + coverage enumeration). Status: tests green (npm test → 10 files / 71 tests passed). Date: 2026-04-11.

This document satisfies Compliance finding #7 by explicitly enumerating which Phase 4 paths are covered by automated unit tests and which require manual QA in a real browser + screen reader. It also records the XSS / unsafe-HTML grep sweep results and any follow-up tickets that must ship before the PM agent checks the eight Phase 4 boxes.


1. Unit-tested paths (automated, npm test)

Path / behaviour Test file
useRoll state transitions (idle → rolling → done) src/__tests__/hooks/use-roll.test.ts
useRoll reduced-motion branch — skips animation timer src/__tests__/hooks/use-roll.test.ts
useRoll pool-capture invariant — concurrent cache mutation cannot change an in-flight roll src/__tests__/hooks/use-roll.test.ts
useRoll re-roll semantics — second roll() rerolls the captured pool src/__tests__/hooks/use-roll.test.ts
useRoll Strict Mode cleanup — double-mount does not leak timers src/__tests__/hooks/use-roll.test.ts
RollAnnouncer DOM-per-state (idle / rolling / done) src/__tests__/components/dice/roll-announcer.test.tsx
RollAnnouncer key increment on re-roll (forces SR re-announce) src/__tests__/components/dice/roll-announcer.test.tsx
RollAnnouncer XSS guard — movie title rendered as text, not HTML src/__tests__/components/dice/roll-announcer.test.tsx
RollAnimation DOM-per-state (idle / rolling / done) src/__tests__/components/dice/roll-animation.test.tsx
RollAnimation reduced-motion branch — skips scatter/flip src/__tests__/components/dice/roll-animation.test.tsx
RollAnimation onComplete lifecycle src/__tests__/components/dice/roll-animation.test.tsx
RollAnimation timer cleanup on unmount (no leaked intervals) src/__tests__/components/dice/roll-animation.test.tsx
RollAnimation XSS guard — poster URL and title not injected as HTML src/__tests__/components/dice/roll-animation.test.tsx
useAllUserMovies RLS-enforced fetch path src/__tests__/hooks/use-all-user-movies.test.ts
useAllUserMovies forged-group simulation — RLS rejects unauthorised group_id src/__tests__/hooks/use-all-user-movies.test.ts
useAllUserMovies dedupe across multiple groups src/__tests__/hooks/use-all-user-movies.test.ts
selectRandomMovie edge cases (empty pool, single item, modulo bias) src/__tests__/dice/randomizer.test.ts
useRealtimeMovies dual invalidation — invalidates both ['group-movies', groupId] and ['all-user-movies', userId] src/__tests__/hooks/use-realtime-movies.test.ts
filterByGenresAndEmotions (string-based, legacy) — token match, no-match fallback src/__tests__/movies/genre-filter.test.ts
Query-key contract stability across hooks src/__tests__/movies/query-keys.test.ts
Real-time cache projection does not drop rows src/__tests__/movies/realtime-cache.test.ts
XSS / unsafe-HTML static grep sweep over Phase 4 surface src/__tests__/a11y/phase4-xss-sweep.test.ts (NEW in U11)

Coverage gaps inside the unit test surface

  • filterByGenresAndEmotionsStructured (U8) — the new structured variant in src/lib/dice/genre-filter.ts is currently used by MovieListClient in-list rolls. The existing genre-filter test file covers the legacy string-based tokenizer. U8 shipped its own tests for the structured function inside genre-filter.test.ts; verify that block exists before closing Phase 4. If the block is absent, file a sub-ticket against U8 — do not retro-add here (U11 does not modify existing test files).

2. Manual-only paths (require real browser + AT)

These paths cannot be meaningfully asserted from jsdom/Vitest. Each must be exercised by a human reviewer before the PM agent checks Phase 4 complete.

2.1 Visual / motion correctness

  • Scatter + flip animation (U5) looks correct in Chrome, Firefox, Safari at 1x and 2x DPI.
  • 30 fps floor on low-end mobile: Chrome DevTools → Performance tab → CPU throttle 4× → record a 3-second roll → confirm no long tasks > 50 ms and frame rate ≥ 30 fps.
  • GPU compositing confirmation: DevTools → Rendering panel → toggle "Layer borders" and "Paint flashing" → confirm the scatter layer promotes to its own compositor layer and does not repaint the surrounding poster grid.
  • prefers-reduced-motion: reduce via DevTools Rendering panel emulation → confirm both RollAnimation and useRoll skip the animation branch and jump straight to the final state.

2.2 Keyboard + focus

  • <GenreRollModal> focus trap: tab cycle stays inside the modal; initial focus lands on the search input; closing the modal restores focus to the RollBar button that opened it.
  • Keyboard-only walk-through — list view: Tab to RollBar → Enter on Roll the Dice → Tab to re-roll button → EnterTab to close.
  • Keyboard-only walk-through — home view: Tab to RollSection → Enter → re-roll → close. Confirm the page does NOT navigate (HomeRollTeaserCard renders in place per CLAUDE.md:45).
  • ESC closes the Genre Roll modal and restores focus.

2.3 Screen reader announcement ordering

Test on VoiceOver (macOS) OR Orca (Linux). Order must be:

  1. User activates Roll → "Rolling…" is announced.
  2. After animation settles → "Rolled <Movie Title> (<Year>)" is announced.
  3. Re-roll → must re-announce (the RollAnnouncer key increment is unit tested, but the actual AT re-announce behaviour is not).
  • VO/Orca on list-view roll (U8).
  • VO/Orca on home-view roll (U9).
  • VO/Orca on Genre Roll with one match.
  • VO/Orca on Genre Roll with no matches (must still announce the fallback winner, with the "no matches" banner visible but NOT spoken twice).

2.4 Axe audit

  • Install axe DevTools browser extension (not npm — it is not in devDependencies and U11 is forbidden from adding dependencies).
  • Scan /home with an active roll result visible → zero serious or critical violations.
  • Scan /list/<id> with the Genre Roll modal open → zero serious or critical violations.
  • Scan /list/<id> with a roll result visible → zero serious or critical violations.

3. XSS / unsafe-HTML grep sweep (2026-04-11)

Sweep targets (the Phase 4 rendering surface):

src/components/dice/
src/components/home/roll-section.tsx
src/components/home/home-roll-teaser-card.tsx
src/components/movies/movie-list-client.tsx

3.1 dangerouslySetInnerHTML

Command:

grep -rn "dangerouslySetInnerHTML" src/components/dice/ \
  src/components/home/roll-section.tsx \
  src/components/home/home-roll-teaser-card.tsx \
  src/components/movies/movie-list-client.tsx

Result:

src/components/dice/roll-animation.tsx:26: * only. No `dangerouslySetInnerHTML`, no unescaped `title=` attributes.
src/components/dice/roll-result-card.tsx:18: * plain React text children. No `dangerouslySetInnerHTML`, no unescaped
src/components/home/home-roll-teaser-card.tsx:14: * `dangerouslySetInnerHTML`, no unescaped `title=` attributes.

All three hits are inside JSDoc block comments — they document the absence of the pattern. Zero production JSX usages. Asserted in src/__tests__/a11y/phase4-xss-sweep.test.ts (has no dangerouslySetInnerHTML test case, run once per surface file after comment stripping).

3.2 title= attribute bindings

Command:

grep -rn "title=" src/components/dice/ \
  src/components/home/roll-section.tsx \
  src/components/home/home-roll-teaser-card.tsx \
  src/components/movies/movie-list-client.tsx

Result (excluding JSDoc comment lines):

File Line Binding Resolved value Safe?
src/components/dice/roll-bar.tsx 53 title={randomTooltip} "Loading…" | "Nothing to roll" | "Roll the dice for a random pick" — all hardcoded in disabledReason() or the constant fallback YES
src/components/dice/roll-bar.tsx 66 title={genreTooltip} "Loading…" | "Nothing to roll" | "Pick genres and moods, then roll" — same provenance YES
src/components/home/roll-section.tsx 91 title={tooltip} "Loading lists…" | "Nothing to roll" | undefined — hardcoded ternary over isLoading / fullPool.length === 0 YES
src/components/home/roll-section.tsx 106 title={tooltip} Same variable, same provenance YES

src/components/home/home-roll-teaser-card.tsx and src/components/movies/movie-list-client.tsx have zero title= bindings outside JSDoc comments.

Conclusion. No title= attribute on the Phase 4 surface carries user-, movie-, group-, or otherwise-interpolated data. Every bound identifier has been added to TITLE_BINDING_ALLOWLIST in src/__tests__/a11y/phase4-xss-sweep.test.ts with an audit note. A new title={someExpr} binding will fail the sweep until an auditor extends the allowlist deliberately.

3.3 Stale U7 import path

Command:

grep -rn "landing/genre-roll-modal" src/

Result: zero matches.

U7 migrated <GenreRollModal> from src/components/landing/ to src/components/dice/. A merge-conflict resurrection of the old path would silently reintroduce an unowned copy, so the sweep asserts that no file references the legacy path.


4. Known follow-up ticket — genre-only home roll falls through to full pool

Ticket: phase4-followup-home-genre-structured-filter Severity: functional defect (not a security issue). Owner: Phase 4 follow-up (must ship before MVP cut). Do NOT fix in U11 — U11 is non-production.

Summary

src/components/home/roll-section.tsx currently calls the legacy string-based filterByGenresAndEmotions(tokens, fullPool) with a token string built via:

const tokens = [...payload.genreIds.map(String), ...payload.moodKeys].join(" ");

This is Option A from the Phase 4 plan (U9 landed the quick fix so the home roll teaser could ship). The legacy tokenizer matches against the movies.genres column, which stores TMDB genre names (e.g. "Action", "Comedy"), not numeric IDs as strings. A genre-only selection produces tokens like "28 12"; these never match any row, so noMatches === true and RollSection.handleGenreRoll falls through to an unfiltered full-pool roll. The user sees noMatchesBanner but their genre filter was silently discarded.

Proper fix

U8 shipped filterByGenresAndEmotionsStructured in src/lib/dice/genre-filter.ts. That variant accepts a structured payload and matches genre IDs against both numeric TMDB IDs and genre names, closing the mismatch. The swap in RollSection is approximately a five-line change:

// BEFORE
const tokens = [...payload.genreIds.map(String), ...payload.moodKeys].join(" ");
const { movies: filtered, noMatches } = filterByGenresAndEmotions(tokens, fullPool);

// AFTER
const { movies: filtered, noMatches } = filterByGenresAndEmotionsStructured(
  { genreIds: payload.genreIds, moodKeys: payload.moodKeys },
  fullPool,
);

Also swap the import at the top of roll-section.tsx. The in-list roll (MovieListClient) already uses the structured variant, so the fix brings the home-view roll in line with the list-view roll.

Why not in U11

U11 is explicitly a test + docs-only unit with a hard "no production code changes" scope guard. File this as a follow-up ticket before the PM agent closes Phase 4.


5. End-to-end recipe (deferred to human reviewer)

The Section 4 E2E walkthrough from PHASE4_PLAN.md (list-view roll → re-roll → Genre Roll modal → home-view roll → cross-list roll → back to list → new roll) is a blocker before the PM agent can check the eight Phase 4 boxes. U11 does NOT attempt to run it — Vitest cannot drive a real browser, a real screen reader, or the Supabase realtime socket. The reviewer must:

  1. Boot the full local stack per CLAUDE.md § Local MVP Testing (Supabase Docker stack + npm run dev).
  2. Seed at least two groups with 6+ movies each, including at least one movie in multiple groups (to exercise useAllUserMovies dedupe).
  3. Run the Section 4 recipe end-to-end on desktop + one mobile form factor.
  4. Confirm every checkbox in §2.1–§2.4 above.
  5. Confirm the follow-up ticket in §4 has been filed (and optionally fixed before MVP cut).

Only then may Phase 4 be marked complete.


6. Running this sweep

npm test -- src/__tests__/a11y/phase4-xss-sweep.test.ts

Expected: 17 passed (17) across three sub-suites (dangerouslySetInnerHTML, title= bindings, and the stale-import guard). Full suite: npm test → 10 files / 71 tests passed.