Spaces:
Runtime error
Runtime error
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' | |
| import { render, screen, cleanup, waitFor, fireEvent, act, within } from '@testing-library/react' | |
| import { readFileSync } from 'node:fs' | |
| import { resolve } from 'node:path' | |
| import { useParams, useLocation } from 'react-router-dom' | |
| import Game from '../pages/Game' | |
| import { setPlayerDrawTower, setPlayer2DrawTower } from '../theme.ts' | |
| const { mockEngine, mockTheme, mockNavigate } = vi.hoisted(() => ({ | |
| mockNavigate: vi.fn(), | |
| mockTheme: { | |
| bgStr: '#0d1117', | |
| text: '#e6edf3', | |
| textMuted: '#8b949e', | |
| bgPanel: 'rgba(22,27,34,0.92)', | |
| border: '#21262d', | |
| player1Str: '#58a6ff', | |
| player2Str: '#f0883e', | |
| rematchBg: '#238636', | |
| rematchBgHover: '#2ea043', | |
| errorBg: 'rgba(35,10,10,0.92)', | |
| error: '#f85149', | |
| playing: '#58a6ff', | |
| waiting: '#8b949e', | |
| win: '#3fb950', | |
| draw: '#d29922', | |
| bg: '#0d1117', | |
| }, | |
| mockEngine: { | |
| status: 'win', | |
| players: ['P1', 'P2'], | |
| currentPlayer: 0, | |
| playerIndex: 0, | |
| winnerPlayerId: '0', | |
| scores: { '0': { wins: 1 } }, | |
| opponentOnline: true, | |
| isMuted: false, | |
| addCell: vi.fn(), | |
| applyMove: vi.fn(), | |
| rejectMove: vi.fn(), | |
| applyWin: vi.fn(), | |
| applyDraw: vi.fn(), | |
| applyRoomState: vi.fn(), | |
| playerLeft: vi.fn(), | |
| setOpponentOnline: vi.fn(), | |
| toggleMute: vi.fn(), | |
| placeLocalMove: vi.fn(), | |
| onStateChanged: vi.fn(), | |
| onError: vi.fn(), | |
| }, | |
| })) | |
| vi.mock('react-router-dom', () => ({ | |
| useParams: vi.fn(() => ({ roomId: 'local' })), | |
| useNavigate: vi.fn(() => mockNavigate), | |
| useLocation: vi.fn(() => ({ state: null })), | |
| })) | |
| vi.mock('../theme.ts', () => ({ | |
| theme: mockTheme, | |
| THEMES: { midnight: { label: 'Midnight', emoji: '🌙' } }, | |
| useThemeMode: vi.fn(() => ['midnight', vi.fn()]), | |
| getThemeMode: vi.fn(() => 'midnight'), | |
| getPlayerName: vi.fn(() => 'Caro 1'), | |
| setPlayerName: vi.fn(), | |
| getPlayer2Name: vi.fn(() => 'Caro 2'), | |
| setPlayer2Name: vi.fn(), | |
| getPlayerSymbol: vi.fn(() => 'X'), | |
| setPlayerSymbol: vi.fn(), | |
| getPlayer1Symbol: vi.fn(() => 'X'), | |
| setPlayer1Symbol: vi.fn(), | |
| getPlayer2Symbol: vi.fn(() => 'O'), | |
| setPlayer2Symbol: vi.fn(), | |
| getPlayerColor: vi.fn(() => null), | |
| setPlayerColor: vi.fn(), | |
| getPlayer2Color: vi.fn(() => null), | |
| setPlayer2Color: vi.fn(), | |
| getPlayerDrawTower: vi.fn(() => true), | |
| setPlayerDrawTower: vi.fn(), | |
| getPlayer2DrawTower: vi.fn(() => true), | |
| setPlayer2DrawTower: vi.fn(), | |
| })) | |
| vi.mock('../components/ThemeSelector.tsx', () => ({ | |
| default: () => null, | |
| })) | |
| vi.mock('../components/SymbolPicker.tsx', () => ({ | |
| default: () => null, | |
| })) | |
| vi.mock('../components/GameCanvas.tsx', () => ({ | |
| default: ({ onEngineReady }: any) => { | |
| setTimeout(() => onEngineReady?.(mockEngine), 0) | |
| return <div data-testid="game-canvas" /> | |
| }, | |
| })) | |
| afterEach(() => { | |
| vi.useRealTimers() | |
| vi.unstubAllGlobals() | |
| cleanup() | |
| }) | |
| beforeEach(() => { | |
| vi.clearAllMocks() | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'test-room' }) | |
| vi.mocked(useLocation).mockReturnValue({ state: null } as any) | |
| mockEngine.status = 'win' | |
| mockEngine.players = ['P1', 'P2'] | |
| mockEngine.playerIndex = 0 | |
| mockEngine.winnerPlayerId = '0' | |
| mockEngine.scores = { '0': { wins: 1 } } | |
| mockEngine.opponentOnline = true | |
| mockEngine.isMuted = false | |
| mockEngine.placeLocalMove.mockReset() | |
| mockNavigate.mockReset() | |
| }) | |
| describe('Game page (with engine)', () => { | |
| it('renders HUD with flexWrap and maxWidth', async () => { | |
| render(<Game />) | |
| const hud = await waitFor(() => { | |
| const el = screen.getByText('test-room').closest('div')! | |
| expect(el).toHaveStyle('maxWidth: min(600px, 90vw)') | |
| return el | |
| }) | |
| expect(hud).toHaveStyle('flexWrap: wrap') | |
| }) | |
| it('renders edit name button with 36px touch target', async () => { | |
| render(<Game />) | |
| const editBtn = await screen.findByTitle('Change your name') | |
| expect(editBtn).toHaveStyle('minWidth: 36px') | |
| expect(editBtn).toHaveStyle('minHeight: 36px') | |
| }) | |
| it('renders local player cards after engine initialization', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'local' }) | |
| vi.mocked(useLocation).mockReturnValue({ state: { player1Name: 'P1', player2Name: 'P2' } } as any) | |
| ;(mockEngine as any).camera = { setInitialZoom: vi.fn() } | |
| ;(mockEngine as any).symbols = { customColors: [], drawTowers: [] } | |
| ;(mockEngine as any).updatePlayerConfig = vi.fn() | |
| ;(mockEngine as any).redraw = vi.fn() | |
| render(<Game />) | |
| expect(await screen.findByTitle('Edit Player 1')).toBeInTheDocument() | |
| expect(await screen.findByTitle('Edit Player 2')).toBeInTheDocument() | |
| }) | |
| it('shows opponent disconnected banner with zIndex 6', async () => { | |
| render(<Game />) | |
| await screen.findByText('test-room') | |
| mockEngine.opponentOnline = false | |
| mockEngine.onStateChanged?.() | |
| await screen.findByText(/opponent disconnected/i) | |
| const banner = screen.getByText(/opponent disconnected/i).closest('div')! | |
| expect(banner).toHaveStyle('zIndex: 6') | |
| }) | |
| it('shows local turn indicator with top z-index', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'local' }) | |
| vi.mocked(useLocation).mockReturnValue({ state: { player1Name: 'P1', player2Name: 'P2' } } as any) | |
| ;(mockEngine as any).camera = { setInitialZoom: vi.fn() } | |
| ;(mockEngine as any).symbols = { customColors: [], drawTowers: [] } | |
| ;(mockEngine as any).updatePlayerConfig = vi.fn() | |
| ;(mockEngine as any).redraw = vi.fn() | |
| render(<Game />) | |
| const turnMsg = await screen.findByText(/Caro 1's turn/i) | |
| const turnDiv = turnMsg.closest('div')! | |
| expect(turnDiv).toHaveStyle('zIndex: 10') | |
| }) | |
| it('opens name modal with Cancel/Save 44px buttons', async () => { | |
| render(<Game />) | |
| const editBtn = await screen.findByTitle('Change your name') | |
| editBtn.click() | |
| const cancelBtn = await screen.findByText('Cancel') | |
| expect(cancelBtn).toHaveStyle('minWidth: 44px') | |
| expect(cancelBtn).toHaveStyle('minHeight: 44px') | |
| const saveBtn = screen.getByText('Save') | |
| expect(saveBtn).toHaveStyle('minWidth: 44px') | |
| expect(saveBtn).toHaveStyle('minHeight: 44px') | |
| }) | |
| it('renders name modal panel with min(320px, 85vw)', async () => { | |
| render(<Game />) | |
| const editBtn = await screen.findByTitle('Change your name') | |
| editBtn.click() | |
| const header = await screen.findByText('Change your name') | |
| const panel = header.parentElement! | |
| const src = readFileSync(resolve(__dirname, '../pages/Game.tsx'), 'utf-8') | |
| expect(src).toContain("width: 'min(320px, 85vw)'") | |
| }) | |
| it('allows toggling the 3D tower effect in local mode player modal settings', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'local' }) | |
| vi.mocked(useLocation).mockReturnValue({ state: { player1Name: 'Caro 1', player2Name: 'Caro 2' } } as any) | |
| // Setup mock properties for local mode | |
| ;(mockEngine as any).camera = { setInitialZoom: vi.fn() } | |
| ;(mockEngine as any).symbols = { customColors: [], drawTowers: [] } | |
| ;(mockEngine as any).updatePlayerConfig = vi.fn() | |
| ;(mockEngine as any).redraw = vi.fn() | |
| render(<Game />) | |
| // Wait for the game screen and engine initialization | |
| const editP1Btn = await screen.findByTitle('Edit Player 1') | |
| editP1Btn.click() | |
| // Verify modal header is rendered | |
| await screen.findByText('Edit Player 1 Settings') | |
| // Find and toggle the 3D Tower Effect checkbox | |
| const checkbox = screen.getByLabelText(/3D Tower Effect/i) as HTMLInputElement | |
| expect(checkbox.checked).toBe(true) | |
| // Click checkbox to uncheck | |
| checkbox.click() | |
| expect(checkbox.checked).toBe(false) | |
| // Save changes | |
| const saveBtn = screen.getByText('Save Changes') | |
| saveBtn.click() | |
| // Assert updatePlayerConfig has been called with drawTowers parameter [false, true] | |
| await waitFor(() => { | |
| expect(mockEngine.updatePlayerConfig).toHaveBeenLastCalledWith( | |
| ['Caro 1', 'Caro 2'], | |
| ['X', 'O'], | |
| [expect.any(Number), expect.any(Number)], | |
| [false, true] | |
| ) | |
| }) | |
| }) | |
| it('renders built-in bot status in local mode', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'local' }) | |
| vi.mocked(useLocation).mockReturnValue({ | |
| state: { | |
| player1Name: 'Bot 1', | |
| player2Name: 'Caro 2', | |
| player1Type: 'bot', | |
| player2Type: 'human', | |
| rules: { swap2: false, noOverlines: false, boardSize: 15 }, | |
| }, | |
| } as any) | |
| ;(mockEngine as any).camera = { setInitialZoom: vi.fn() } | |
| ;(mockEngine as any).symbols = { customColors: [], drawTowers: [] } | |
| ;(mockEngine as any).updatePlayerConfig = vi.fn() | |
| ;(mockEngine as any).redraw = vi.fn() | |
| render(<Game />) | |
| await waitFor(() => expect((mockEngine as any).manager).toBeTruthy()) | |
| await screen.findByText(/Caro 1's turn \(Bot\)/) | |
| }) | |
| it('renders the Swap2 step guide and shrinks the board fit in local mode', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'local' }) | |
| vi.mocked(useLocation).mockReturnValue({ | |
| state: { | |
| player1Name: 'Caro 1', | |
| player2Name: 'Caro 2', | |
| player1Type: 'human', | |
| player2Type: 'human', | |
| rules: { swap2: true, noOverlines: true, boardSize: 15 }, | |
| }, | |
| } as any) | |
| ;(mockEngine as any).camera = { setInitialZoom: vi.fn() } | |
| ;(mockEngine as any).symbols = { customColors: [], drawTowers: [] } | |
| ;(mockEngine as any).updatePlayerConfig = vi.fn() | |
| ;(mockEngine as any).redraw = vi.fn() | |
| render(<Game />) | |
| const guide = await screen.findByLabelText('Swap2 steps') | |
| expect(guide).toBeInTheDocument() | |
| expect(screen.getByText('1. Place three stones')).toHaveStyle(`color: ${mockTheme.player1Str}`) | |
| expect(screen.getByText('2. Swap2 Choices')).toBeInTheDocument() | |
| expect(screen.getByText('3. Continue the game')).toBeInTheDocument() | |
| expect((mockEngine as any).camera.setInitialZoom).toHaveBeenCalledWith(15, 0.66) | |
| }) | |
| it('hides the Swap2 guide after the first normal move following the choice', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'local' }) | |
| vi.mocked(useLocation).mockReturnValue({ | |
| state: { | |
| player1Name: 'Caro 1', | |
| player2Name: 'Caro 2', | |
| player1Type: 'human', | |
| player2Type: 'human', | |
| rules: { swap2: true, noOverlines: true, boardSize: 15 }, | |
| }, | |
| } as any) | |
| ;(mockEngine as any).camera = { setInitialZoom: vi.fn() } | |
| ;(mockEngine as any).symbols = { customColors: [], drawTowers: [] } | |
| ;(mockEngine as any).updatePlayerConfig = vi.fn() | |
| ;(mockEngine as any).redraw = vi.fn() | |
| render(<Game />) | |
| await screen.findByLabelText('Swap2 steps') | |
| act(() => { | |
| ;(mockEngine as any).manager.placeMove(7, 7) | |
| ;(mockEngine as any).manager.placeMove(8, 7) | |
| ;(mockEngine as any).manager.placeMove(9, 7) | |
| ;(mockEngine as any).manager.chooseSwap(false) | |
| ;(mockEngine as any).manager.placeMove(10, 7) | |
| ;(mockEngine as any).onStateChanged?.() | |
| }) | |
| await waitFor(() => expect(screen.queryByLabelText('Swap2 steps')).not.toBeInTheDocument()) | |
| }) | |
| it('shows + New Bot while waiting and starts a local bot match', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'waiting-room' }) | |
| vi.mocked(useLocation).mockReturnValue({ state: null } as any) | |
| mockEngine.status = 'waiting' | |
| mockEngine.players = ['Caro 1'] | |
| const view = render(<Game />) | |
| const newBot = await view.findByText('+ New Bot') | |
| fireEvent.click(newBot) | |
| expect(mockNavigate).toHaveBeenCalledWith('/play/local', { | |
| state: { | |
| player1Name: 'Caro 1', | |
| player2Name: 'Bot 2', | |
| player1Symbol: 'X', | |
| player2Symbol: 'O', | |
| player1Type: 'human', | |
| player2Type: 'bot', | |
| botProfile: 'normal', | |
| rules: { swap2: true, noOverlines: true, boardSize: 15 }, | |
| }, | |
| }) | |
| }) | |
| it('calls the server bot endpoint before placing a local bot move', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'local' }) | |
| vi.mocked(useLocation).mockReturnValue({ | |
| state: { | |
| player1Name: 'Bot 1', | |
| player2Name: 'Caro 2', | |
| player1Type: 'bot', | |
| player2Type: 'human', | |
| botProfile: 'expert', | |
| rules: { swap2: false, noOverlines: false, boardSize: 15 }, | |
| }, | |
| } as any) | |
| const fetchMock = vi.fn(async () => ({ | |
| ok: true, | |
| json: async () => ({ move: { x: 7, y: 7 }, source: 'alpha_beta' }), | |
| })) | |
| vi.stubGlobal('fetch', fetchMock) | |
| vi.useFakeTimers() | |
| ;(mockEngine as any).camera = { setInitialZoom: vi.fn() } | |
| ;(mockEngine as any).symbols = { customColors: [], drawTowers: [] } | |
| ;(mockEngine as any).updatePlayerConfig = vi.fn() | |
| ;(mockEngine as any).redraw = vi.fn() | |
| render(<Game />) | |
| await act(async () => { | |
| vi.advanceTimersByTime(0) | |
| await Promise.resolve() | |
| }) | |
| expect((mockEngine as any).manager).toBeTruthy() | |
| fetchMock.mockClear() | |
| await act(async () => { | |
| ;(mockEngine as any).status = 'active' | |
| ;(mockEngine as any).currentPlayer = 0 | |
| ;(mockEngine as any).onStateChanged?.() | |
| vi.advanceTimersByTime(221) | |
| await Promise.resolve() | |
| await Promise.resolve() | |
| }) | |
| expect(fetchMock).toHaveBeenCalledWith('/api/bot/move', expect.objectContaining({ | |
| method: 'POST', | |
| headers: { 'content-type': 'application/json' }, | |
| })) | |
| const [, options] = fetchMock.mock.calls[0] | |
| const body = JSON.parse((options as RequestInit).body as string) | |
| expect(body.currentPlayer).toBe(0) | |
| expect(body.profile).toBe('expert') | |
| expect(body.rules).toEqual({ swap2: false, noOverlines: false, boardSize: 15 }) | |
| expect(mockEngine.placeLocalMove).toHaveBeenCalledWith(7, 7) | |
| }) | |
| it('swaps player symbols and colors next to the player cards when local swap is applied', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'local' }) | |
| vi.mocked(useLocation).mockReturnValue({ state: { player1Name: 'Caro 1', player2Name: 'Caro 2' } } as any) | |
| // Setup mock properties for local mode | |
| ;(mockEngine as any).camera = { setInitialZoom: vi.fn() } | |
| ;(mockEngine as any).symbols = { customColors: [], drawTowers: [] } | |
| ;(mockEngine as any).updatePlayerConfig = vi.fn() | |
| ;(mockEngine as any).redraw = vi.fn() | |
| render(<Game />) | |
| // Wait for engine to initialize and edit buttons to render | |
| await screen.findByTitle('Edit Player 1') | |
| // Simulate engine swap values (names stay same, symbols swap) | |
| ;(mockEngine as any).players = ['Caro 1', 'Caro 2'] | |
| ;(mockEngine as any).playerSymbols = ['O', 'X'] | |
| // Simulate swap applied | |
| act(() => { | |
| ;(mockEngine as any).onSwapApplied?.() | |
| }) | |
| // Now Player 1 Card should display "Caro 1" and "O" | |
| const card1 = await screen.findByTitle('Edit Player 1') | |
| expect(within(card1).getByText(/Caro 1/)).toBeInTheDocument() | |
| expect(within(card1).getByText('O')).toBeInTheDocument() | |
| // Now Player 2 Card should display "Caro 2" and "X" | |
| const card2 = await screen.findByTitle('Edit Player 2') | |
| expect(within(card2).getByText(/Caro 2/)).toBeInTheDocument() | |
| expect(within(card2).getByText('X')).toBeInTheDocument() | |
| }) | |
| it('does not swap player colors or tower settings when local swap is applied with swapped=false', async () => { | |
| vi.mocked(useParams).mockReturnValue({ roomId: 'local' }) | |
| vi.mocked(useLocation).mockReturnValue({ state: { player1Name: 'Caro 1', player2Name: 'Caro 2' } } as any) | |
| // Setup mock properties for local mode | |
| ;(mockEngine as any).camera = { setInitialZoom: vi.fn() } | |
| ;(mockEngine as any).symbols = { customColors: [], drawTowers: [] } | |
| ;(mockEngine as any).updatePlayerConfig = vi.fn() | |
| ;(mockEngine as any).redraw = vi.fn() | |
| render(<Game />) | |
| // Wait for engine to initialize and edit buttons to render | |
| await screen.findByTitle('Edit Player 1') | |
| // Reset calls on mock functions | |
| vi.mocked(setPlayerDrawTower).mockClear() | |
| vi.mocked(setPlayer2DrawTower).mockClear() | |
| // Simulate engine values (names and symbols stay same) | |
| ;(mockEngine as any).players = ['Caro 1', 'Caro 2'] | |
| ;(mockEngine as any).playerSymbols = ['X', 'O'] | |
| // Simulate swap applied with false (stay) | |
| act(() => { | |
| ;(mockEngine as any).onSwapApplied?.(false) | |
| }) | |
| // Assert that tower settings were not updated (since no swap happened) | |
| expect(setPlayerDrawTower).not.toHaveBeenCalled() | |
| expect(setPlayer2DrawTower).not.toHaveBeenCalled() | |
| // Now Player 1 Card should still display "Caro 1" and "X" | |
| const card1 = await screen.findByTitle('Edit Player 1') | |
| expect(within(card1).getByText(/Caro 1/)).toBeInTheDocument() | |
| expect(within(card1).getByText('X')).toBeInTheDocument() | |
| // Now Player 2 Card should still display "Caro 2" and "O" | |
| const card2 = await screen.findByTitle('Edit Player 2') | |
| expect(within(card2).getByText(/Caro 2/)).toBeInTheDocument() | |
| expect(within(card2).getByText('O')).toBeInTheDocument() | |
| }) | |
| }) | |