caro5 / client /src /__tests__ /Game.engine.test.tsx
Pedro de Carvalho
Update tests
517bb12
Raw
History Blame Contribute Delete
17 kB
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()
})
})