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
}, })) 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() 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() 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() 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() 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() 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() 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() 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() // 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() 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() 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() 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() 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() 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() // 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() // 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() }) })