diff --git a/.gitignore b/.gitignore index b721bff..dcc409c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +/test-results # next.js /.next/ diff --git a/package-lock.json b/package-lock.json index ce70c34..5964f19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "react-dom": "19.2.4" }, "devDependencies": { + "@axe-core/playwright": "4.12.1", "@playwright/test": "^1.49.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", @@ -103,6 +104,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -3369,9 +3383,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", - "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, "license": "MPL-2.0", "engines": { diff --git a/package.json b/package.json index a5f9a30..e872560 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@playwright/test": "^1.49.1", + "@axe-core/playwright": "4.12.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/src/__tests__/GameDetailPage.test.tsx b/src/__tests__/GameDetailPage.test.tsx new file mode 100644 index 0000000..9c6ad46 --- /dev/null +++ b/src/__tests__/GameDetailPage.test.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockPush = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +const mockAuth = { status: 'anonymous' as string }; +vi.mock('@/features/auth/AuthProvider', () => ({ + useAuth: () => ({ status: mockAuth.status }), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); + mockPush.mockReset(); + mockAuth.status = 'anonymous'; +}); + +function game(overrides: Partial> = {}) { + return { + slug: 'falling-blocks', name: 'Falling Blocks', summary: 'Stack the pieces.', rulesSummary: 'Clear lines.', + category: 'puzzle', tags: ['puzzle', 'logic'], difficulty: 'Medium', + estimatedDurationMinMinutes: 5, estimatedDurationMaxMinutes: 10, + minPlayers: 1, maxPlayers: 1, lifecycle: 'Available', capabilities: ['ai'], + featuredRank: null, artToken: 'falling-blocks', artColorA: '#F0394B', artColorB: '#111', + artAltText: 'Falling Blocks art', + entryActions: [ + { action: 'quick-match', status: 'deferred', reasonCode: 'Games.AwaitingLobby', ownerModule: 6 }, + { action: 'play-vs-ai', status: 'deferred', reasonCode: 'Games.AwaitingAiEngine', ownerModule: 9 }, + ], + ...overrides, + }; +} + +function jsonResponse(status: number, body: unknown) { + return Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => Promise.resolve(body) }); +} + +function setupDetailFetch(handler: (url: string, opts?: { method?: string }) => Promise | null) { + mockFetch.mockImplementation((url: string, opts?: { method?: string }) => { + const result = handler(String(url), opts); + if (result) return result; + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({ error: { code: 'Games.NotFound', message: 'Not found' } }) }); + }); +} + +async function renderPage(gameId = 'falling-blocks') { + const { GameDetailPage } = await import('@/features/games/GameDetailPage'); + return render(); +} + +describe('GameDetailPage', () => { + it('renders a 404 in-page message for an unknown game (never a fallback game)', async () => { + setupDetailFetch(u => { + if (u.includes('/api/games/ghost')) return jsonResponse(404, { error: { code: 'Games.NotFound', message: 'Not found' } }); + return null; + }); + await renderPage('ghost'); + await waitFor(() => expect(screen.getByText("This game isn't available.")).toBeInTheDocument()); + expect(screen.queryByText('Falling Blocks')).not.toBeInTheDocument(); + }); + + it('renders a distinct tombstone state for a 410 Retired game (not the 404 message)', async () => { + setupDetailFetch(u => { + if (u.includes('/api/games/old-game')) { + return Promise.resolve({ ok: false, status: 410, json: () => Promise.resolve({ slug: 'old-game', name: 'Old Game', lifecycle: 'Retired', reasonCode: 'Games.Retired' }) }); + } + return null; + }); + await renderPage('old-game'); + await waitFor(() => expect(screen.getByText('Old Game has been retired.')).toBeInTheDocument()); + expect(screen.queryByText("This game isn't available.")).not.toBeInTheDocument(); + }); + + it('shows an error state with retry on a server failure', async () => { + let calls = 0; + setupDetailFetch(u => { + if (u.includes('/api/games/falling-blocks')) { + calls++; + return calls === 1 + ? jsonResponse(500, { error: { code: 'Server.Error', message: 'Server broke' } }) + : jsonResponse(200, game()); + } + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getByText('Server broke')).toBeInTheDocument()); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Retry' })); }); + await waitFor(() => expect(screen.getAllByText('Falling Blocks').length).toBeGreaterThan(0)); + }); + + it('renders disabled entry actions naming their real owner module, not a generic label', async () => { + setupDetailFetch(u => { + if (u.includes('/api/games/falling-blocks')) return jsonResponse(200, game()); + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getAllByText('Falling Blocks').length).toBeGreaterThan(0)); + expect(screen.getByText('Available once Module 6 — Lobby & Matchmaking System ships.')).toBeInTheDocument(); + expect(screen.getByText('Available once Module 9 — Solo vs AI Platform Flow ships.')).toBeInTheDocument(); + const quickMatchBtn = screen.getByText('Quick match').closest('button'); + expect(quickMatchBtn).toBeDisabled(); + }); + + it('does not render fake stats/leaderboard data — shows a deferred empty state instead', async () => { + setupDetailFetch(u => { + if (u.includes('/api/games/falling-blocks')) return jsonResponse(200, game()); + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getAllByText('Falling Blocks').length).toBeGreaterThan(0)); + await act(async () => { fireEvent.click(screen.getByRole('tab', { name: 'Your stats' })); }); + await waitFor(() => expect(screen.getByText('No stats yet.')).toBeInTheDocument()); + expect(screen.getByText('Available once Module 10 — Stats, Achievements & Leaderboards ships.')).toBeInTheDocument(); + }); + + it('anonymous favorite click shows a sign-in prompt instead of mutating', async () => { + setupDetailFetch(u => { + if (u.includes('/api/games/falling-blocks')) return jsonResponse(200, game()); + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getAllByText('Falling Blocks').length).toBeGreaterThan(0)); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Favorite/i })); }); + await waitFor(() => expect(screen.getByText('Sign in to favorite games.')).toBeInTheDocument()); + const favoriteCall = mockFetch.mock.calls.find(c => String(c[0]).includes('/favorites/')); + expect(favoriteCall).toBeUndefined(); + }); + + it('authenticated: favorite toggle is optimistic and rolls back on failure', async () => { + mockAuth.status = 'authenticated'; + setupDetailFetch(u => { + if (u.includes('/api/games/falling-blocks') && !u.includes('favorites')) return jsonResponse(200, game()); + if (u.includes('/api/games/me/favorites') && !u.includes('/falling-blocks')) return jsonResponse(200, { items: [], nextCursor: null }); + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getAllByText('Falling Blocks').length).toBeGreaterThan(0)); + // Favorite state resolves to "not favorited" after paging. + await waitFor(() => expect(screen.getByRole('button', { name: /Favorite/i })).toHaveAttribute('aria-pressed', 'false')); + + mockFetch.mockImplementation((url: string) => { + const u = String(url); + if (u.includes('/api/games/me/favorites/falling-blocks')) { + return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({ error: { code: 'Server.Error', message: 'Failed to update favorite.' } }) }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Favorite/i })); }); + // Optimistic flip then rollback to false on error. + await waitFor(() => expect(screen.getByRole('button', { name: /Favorite/i })).toHaveAttribute('aria-pressed', 'false')); + await waitFor(() => expect(screen.getByText('Failed to update favorite.')).toBeInTheDocument()); + }); + + it('renders game art with an accessible alt label even for an unrecognized art token (broken-media fallback)', async () => { + setupDetailFetch(u => { + if (u.includes('/api/games/mystery-game')) return jsonResponse(200, game({ slug: 'mystery-game', name: 'Mystery Game', artToken: 'unknown-token' })); + return null; + }); + await renderPage('mystery-game'); + await waitFor(() => expect(screen.getByRole('img', { name: 'Falling Blocks art' })).toBeInTheDocument()); + }); + + it('favorite toggle is keyboard operable (a real ))} diff --git a/src/components/ui/GameArt.tsx b/src/components/ui/GameArt.tsx index 5611613..9e573b5 100644 --- a/src/components/ui/GameArt.tsx +++ b/src/components/ui/GameArt.tsx @@ -1,6 +1,5 @@ 'use client'; import React from 'react'; -import type { Game } from '@/types'; function ArtPattern({ kind, a }: { kind: string; a: string }) { if (kind === 'sudoku') return ( @@ -13,14 +12,14 @@ function ArtPattern({ kind, a }: { kind: string; a: string }) { 3 ); - if (kind === 'tetris') return ( + if (kind === 'falling-blocks') return ( {[[20,60],[40,60],[60,60],[40,40]].map(([x,y],i) => )} {[[100,80],[120,80],[120,60],[140,60]].map(([x,y],i) => )} {[[160,30],[160,50],[160,70],[160,90]].map(([x,y],i) => )} ); - if (kind === 'c4') return ( + if (kind === 'four-in-a-row') return ( {Array.from({length:5}).map((_,r) => Array.from({length:7}).map((_2,c) => { const v = (r+c) % 5; @@ -48,7 +47,7 @@ function ArtPattern({ kind, a }: { kind: string; a: string }) { }))} ); - if (kind === 'word') { + if (kind === 'five-letter') { const word = ['S','I','M','P','L','E']; return ( @@ -75,29 +74,40 @@ function ArtPattern({ kind, a }: { kind: string; a: string }) { ); - return null; + // Neutral fallback for an unrecognized art token — never renders nothing (broken-media state). + return ( + + {Array.from({ length: 5 }).map((_, r) => Array.from({ length: 8 }).map((_2, c) => ( + + )))} + + ); +} + +interface GameArtGame { + artToken: string; + artColorA: string; + artColorB: string; + artAltText: string; + name: string; } interface GameArtProps { - game: Pick; + game: GameArtGame; + /** Short derived line under the name, e.g. "Puzzle · Logic" — never a fabricated stat. */ + subtitle?: string; h?: number | string; } -export function GameArt({ game, h = 140 }: GameArtProps) { - const { kind, a, b } = game.art; +export function GameArt({ game, subtitle, h = 140 }: GameArtProps) { + const { artToken: kind, artColorA: a, artColorB: b, artAltText } = game; const bg = `radial-gradient(120% 80% at 80% 10%, ${a}33, transparent 60%), linear-gradient(180deg, ${b}, #07090F)`; return ( -
+
{game.name}
-
{game.tag}
-
-
- - - {game.online.toLocaleString()} online - + {subtitle &&
{subtitle}
}
); diff --git a/src/features/auth/AuthPage.tsx b/src/features/auth/AuthPage.tsx index c87f642..07b66d5 100644 --- a/src/features/auth/AuthPage.tsx +++ b/src/features/auth/AuthPage.tsx @@ -471,13 +471,16 @@ function ProfileSetup({ draft, onDone: _onDone }: { draft: RegisterDraft; onDone } function Field({ label, hint, children }: { label:string; hint?: React.ReactNode; children: React.ReactNode }) { + // `children` (the real input) is placed before the hint in DOM order so the browser's implicit + // label-to-control association resolves to the input, not to an interactive hint (e.g. "Forgot?"). + // CSS `order` restores the original label-then-input visual layout. return ( -