Spaces:
Runtime error
Runtime error
Pedro de Carvalho commited on
Commit ·
4fa92d9
1
Parent(s): 0c380b8
Add ui tests
Browse files- client/src/__tests__/Game.engine.test.tsx +472 -0
- client/src/__tests__/Game.test.tsx +119 -0
- client/src/__tests__/GeneratedReplay.test.tsx +213 -0
- client/src/__tests__/Landing.test.tsx +200 -0
- client/src/__tests__/SymbolPicker.test.tsx +74 -0
- client/src/__tests__/css.test.ts +33 -0
- client/src/__tests__/html.test.ts +40 -0
- client/src/__tests__/setup.ts +1 -0
client/src/__tests__/Game.engine.test.tsx
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
| 2 |
+
import { render, screen, cleanup, waitFor, fireEvent, act, within } from '@testing-library/react'
|
| 3 |
+
import { readFileSync } from 'node:fs'
|
| 4 |
+
import { resolve } from 'node:path'
|
| 5 |
+
import { useParams, useLocation } from 'react-router-dom'
|
| 6 |
+
import Game from '../pages/Game'
|
| 7 |
+
import { setPlayerDrawTower, setPlayer2DrawTower } from '../theme.ts'
|
| 8 |
+
|
| 9 |
+
const { mockEngine, mockTheme, mockNavigate } = vi.hoisted(() => ({
|
| 10 |
+
mockNavigate: vi.fn(),
|
| 11 |
+
mockTheme: {
|
| 12 |
+
bgStr: '#0d1117',
|
| 13 |
+
text: '#e6edf3',
|
| 14 |
+
textMuted: '#8b949e',
|
| 15 |
+
bgPanel: 'rgba(22,27,34,0.92)',
|
| 16 |
+
border: '#21262d',
|
| 17 |
+
player1Str: '#58a6ff',
|
| 18 |
+
player2Str: '#f0883e',
|
| 19 |
+
rematchBg: '#238636',
|
| 20 |
+
rematchBgHover: '#2ea043',
|
| 21 |
+
errorBg: 'rgba(35,10,10,0.92)',
|
| 22 |
+
error: '#f85149',
|
| 23 |
+
playing: '#58a6ff',
|
| 24 |
+
waiting: '#8b949e',
|
| 25 |
+
win: '#3fb950',
|
| 26 |
+
draw: '#d29922',
|
| 27 |
+
bg: '#0d1117',
|
| 28 |
+
},
|
| 29 |
+
mockEngine: {
|
| 30 |
+
status: 'win',
|
| 31 |
+
players: ['P1', 'P2'],
|
| 32 |
+
currentPlayer: 0,
|
| 33 |
+
playerIndex: 0,
|
| 34 |
+
winnerPlayerId: '0',
|
| 35 |
+
scores: { '0': { wins: 1 } },
|
| 36 |
+
opponentOnline: true,
|
| 37 |
+
isMuted: false,
|
| 38 |
+
addCell: vi.fn(),
|
| 39 |
+
applyMove: vi.fn(),
|
| 40 |
+
rejectMove: vi.fn(),
|
| 41 |
+
applyWin: vi.fn(),
|
| 42 |
+
applyDraw: vi.fn(),
|
| 43 |
+
applyRoomState: vi.fn(),
|
| 44 |
+
playerLeft: vi.fn(),
|
| 45 |
+
setOpponentOnline: vi.fn(),
|
| 46 |
+
toggleMute: vi.fn(),
|
| 47 |
+
placeLocalMove: vi.fn(),
|
| 48 |
+
sendMessage: vi.fn(),
|
| 49 |
+
onStateChanged: vi.fn(),
|
| 50 |
+
onError: vi.fn(),
|
| 51 |
+
},
|
| 52 |
+
}))
|
| 53 |
+
|
| 54 |
+
vi.mock('react-router-dom', () => ({
|
| 55 |
+
useParams: vi.fn(() => ({ roomId: 'test-room' })),
|
| 56 |
+
useNavigate: vi.fn(() => mockNavigate),
|
| 57 |
+
useLocation: vi.fn(() => ({ state: null })),
|
| 58 |
+
}))
|
| 59 |
+
|
| 60 |
+
vi.mock('../services/socket.ts', () => ({
|
| 61 |
+
sendMessage: vi.fn(),
|
| 62 |
+
onMessage: vi.fn(() => vi.fn()),
|
| 63 |
+
onReconnect: vi.fn(() => vi.fn()),
|
| 64 |
+
onConnectionChange: vi.fn(() => vi.fn()),
|
| 65 |
+
getConnectionStatus: vi.fn(() => 'connected'),
|
| 66 |
+
}))
|
| 67 |
+
|
| 68 |
+
vi.mock('../theme.ts', () => ({
|
| 69 |
+
theme: mockTheme,
|
| 70 |
+
THEMES: { midnight: { label: 'Midnight', emoji: '🌙' } },
|
| 71 |
+
useThemeMode: vi.fn(() => ['midnight', vi.fn()]),
|
| 72 |
+
getThemeMode: vi.fn(() => 'midnight'),
|
| 73 |
+
getPlayerName: vi.fn(() => 'Caro 1'),
|
| 74 |
+
setPlayerName: vi.fn(),
|
| 75 |
+
getPlayer2Name: vi.fn(() => 'Caro 2'),
|
| 76 |
+
setPlayer2Name: vi.fn(),
|
| 77 |
+
getPlayerSymbol: vi.fn(() => 'X'),
|
| 78 |
+
setPlayerSymbol: vi.fn(),
|
| 79 |
+
getPlayer1Symbol: vi.fn(() => 'X'),
|
| 80 |
+
setPlayer1Symbol: vi.fn(),
|
| 81 |
+
getPlayer2Symbol: vi.fn(() => 'O'),
|
| 82 |
+
setPlayer2Symbol: vi.fn(),
|
| 83 |
+
getPlayerColor: vi.fn(() => null),
|
| 84 |
+
setPlayerColor: vi.fn(),
|
| 85 |
+
getPlayer2Color: vi.fn(() => null),
|
| 86 |
+
setPlayer2Color: vi.fn(),
|
| 87 |
+
getPlayerDrawTower: vi.fn(() => true),
|
| 88 |
+
setPlayerDrawTower: vi.fn(),
|
| 89 |
+
getPlayer2DrawTower: vi.fn(() => true),
|
| 90 |
+
setPlayer2DrawTower: vi.fn(),
|
| 91 |
+
}))
|
| 92 |
+
|
| 93 |
+
vi.mock('../components/ThemeSelector.tsx', () => ({
|
| 94 |
+
default: () => null,
|
| 95 |
+
}))
|
| 96 |
+
|
| 97 |
+
vi.mock('../components/SymbolPicker.tsx', () => ({
|
| 98 |
+
default: () => null,
|
| 99 |
+
}))
|
| 100 |
+
|
| 101 |
+
vi.mock('../components/GameCanvas.tsx', () => ({
|
| 102 |
+
default: ({ onEngineReady }: any) => {
|
| 103 |
+
setTimeout(() => onEngineReady?.(mockEngine), 0)
|
| 104 |
+
return <div data-testid="game-canvas" />
|
| 105 |
+
},
|
| 106 |
+
}))
|
| 107 |
+
|
| 108 |
+
afterEach(() => {
|
| 109 |
+
vi.useRealTimers()
|
| 110 |
+
vi.unstubAllGlobals()
|
| 111 |
+
cleanup()
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
beforeEach(() => {
|
| 115 |
+
vi.clearAllMocks()
|
| 116 |
+
vi.mocked(useParams).mockReturnValue({ roomId: 'test-room' })
|
| 117 |
+
vi.mocked(useLocation).mockReturnValue({ state: null } as any)
|
| 118 |
+
mockEngine.status = 'win'
|
| 119 |
+
mockEngine.players = ['P1', 'P2']
|
| 120 |
+
mockEngine.playerIndex = 0
|
| 121 |
+
mockEngine.winnerPlayerId = '0'
|
| 122 |
+
mockEngine.scores = { '0': { wins: 1 } }
|
| 123 |
+
mockEngine.opponentOnline = true
|
| 124 |
+
mockEngine.isMuted = false
|
| 125 |
+
mockEngine.placeLocalMove.mockReset()
|
| 126 |
+
mockNavigate.mockReset()
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
describe('Game page (with engine)', () => {
|
| 130 |
+
it('renders HUD with flexWrap and maxWidth', async () => {
|
| 131 |
+
render(<Game />)
|
| 132 |
+
const hud = await waitFor(() => {
|
| 133 |
+
const el = screen.getByText('test-room').closest('div')!
|
| 134 |
+
expect(el).toHaveStyle('maxWidth: min(600px, 90vw)')
|
| 135 |
+
return el
|
| 136 |
+
})
|
| 137 |
+
expect(hud).toHaveStyle('flexWrap: wrap')
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
it('renders edit name button with 36px touch target', async () => {
|
| 141 |
+
render(<Game />)
|
| 142 |
+
const editBtn = await screen.findByTitle('Change your name')
|
| 143 |
+
expect(editBtn).toHaveStyle('minWidth: 36px')
|
| 144 |
+
expect(editBtn).toHaveStyle('minHeight: 36px')
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
it('renders Rematch button with 44px touch target', async () => {
|
| 148 |
+
render(<Game />)
|
| 149 |
+
const rematchBtn = await screen.findByText('Rematch')
|
| 150 |
+
expect(rematchBtn).toHaveStyle('minWidth: 44px')
|
| 151 |
+
expect(rematchBtn).toHaveStyle('minHeight: 44px')
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
it('shows opponent disconnected banner with zIndex 6', async () => {
|
| 155 |
+
render(<Game />)
|
| 156 |
+
await screen.findByText('Rematch')
|
| 157 |
+
mockEngine.opponentOnline = false
|
| 158 |
+
mockEngine.onStateChanged?.()
|
| 159 |
+
await screen.findByText(/opponent disconnected/i)
|
| 160 |
+
const banner = screen.getByText(/opponent disconnected/i).closest('div')!
|
| 161 |
+
expect(banner).toHaveStyle('zIndex: 6')
|
| 162 |
+
})
|
| 163 |
+
|
| 164 |
+
it('shows turn indicator with maxWidth 90vw', async () => {
|
| 165 |
+
render(<Game />)
|
| 166 |
+
const turnMsg = await screen.findByText(/P1 wins/i)
|
| 167 |
+
const turnDiv = turnMsg.closest('div')!
|
| 168 |
+
expect(turnDiv).toHaveStyle('maxWidth: 90vw')
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
it('opens name modal with Cancel/Save 44px buttons', async () => {
|
| 172 |
+
render(<Game />)
|
| 173 |
+
const editBtn = await screen.findByTitle('Change your name')
|
| 174 |
+
editBtn.click()
|
| 175 |
+
const cancelBtn = await screen.findByText('Cancel')
|
| 176 |
+
expect(cancelBtn).toHaveStyle('minWidth: 44px')
|
| 177 |
+
expect(cancelBtn).toHaveStyle('minHeight: 44px')
|
| 178 |
+
const saveBtn = screen.getByText('Save')
|
| 179 |
+
expect(saveBtn).toHaveStyle('minWidth: 44px')
|
| 180 |
+
expect(saveBtn).toHaveStyle('minHeight: 44px')
|
| 181 |
+
})
|
| 182 |
+
|
| 183 |
+
it('renders name modal panel with min(320px, 85vw)', async () => {
|
| 184 |
+
render(<Game />)
|
| 185 |
+
const editBtn = await screen.findByTitle('Change your name')
|
| 186 |
+
editBtn.click()
|
| 187 |
+
const header = await screen.findByText('Change your name')
|
| 188 |
+
const panel = header.parentElement!
|
| 189 |
+
const src = readFileSync(resolve(__dirname, '../pages/Game.tsx'), 'utf-8')
|
| 190 |
+
expect(src).toContain("width: 'min(320px, 85vw)'")
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
it('allows toggling the 3D tower effect in local mode player modal settings', async () => {
|
| 194 |
+
vi.mocked(useParams).mockReturnValue({ roomId: 'local' })
|
| 195 |
+
vi.mocked(useLocation).mockReturnValue({ state: { player1Name: 'Caro 1', player2Name: 'Caro 2' } } as any)
|
| 196 |
+
|
| 197 |
+
// Setup mock properties for local mode
|
| 198 |
+
;(mockEngine as any).camera = { setInitialZoom: vi.fn() }
|
| 199 |
+
;(mockEngine as any).symbols = { customColors: [], drawTowers: [] }
|
| 200 |
+
;(mockEngine as any).updatePlayerConfig = vi.fn()
|
| 201 |
+
;(mockEngine as any).redraw = vi.fn()
|
| 202 |
+
|
| 203 |
+
render(<Game />)
|
| 204 |
+
|
| 205 |
+
// Wait for the game screen and engine initialization
|
| 206 |
+
const editP1Btn = await screen.findByTitle('Edit Player 1')
|
| 207 |
+
editP1Btn.click()
|
| 208 |
+
|
| 209 |
+
// Verify modal header is rendered
|
| 210 |
+
await screen.findByText('Edit Player 1 Settings')
|
| 211 |
+
|
| 212 |
+
// Find and toggle the 3D Tower Effect checkbox
|
| 213 |
+
const checkbox = screen.getByLabelText(/3D Tower Effect/i) as HTMLInputElement
|
| 214 |
+
expect(checkbox.checked).toBe(true)
|
| 215 |
+
|
| 216 |
+
// Click checkbox to uncheck
|
| 217 |
+
checkbox.click()
|
| 218 |
+
expect(checkbox.checked).toBe(false)
|
| 219 |
+
|
| 220 |
+
// Save changes
|
| 221 |
+
const saveBtn = screen.getByText('Save Changes')
|
| 222 |
+
saveBtn.click()
|
| 223 |
+
|
| 224 |
+
// Assert updatePlayerConfig has been called with drawTowers parameter [false, true]
|
| 225 |
+
await waitFor(() => {
|
| 226 |
+
expect(mockEngine.updatePlayerConfig).toHaveBeenLastCalledWith(
|
| 227 |
+
['Caro 1', 'Caro 2'],
|
| 228 |
+
['X', 'O'],
|
| 229 |
+
[expect.any(Number), expect.any(Number)],
|
| 230 |
+
[false, true]
|
| 231 |
+
)
|
| 232 |
+
})
|
| 233 |
+
})
|
| 234 |
+
|
| 235 |
+
it('renders built-in bot status in local mode', async () => {
|
| 236 |
+
vi.mocked(useParams).mockReturnValue({ roomId: 'local' })
|
| 237 |
+
vi.mocked(useLocation).mockReturnValue({
|
| 238 |
+
state: {
|
| 239 |
+
player1Name: 'Bot 1',
|
| 240 |
+
player2Name: 'Caro 2',
|
| 241 |
+
player1Type: 'bot',
|
| 242 |
+
player2Type: 'human',
|
| 243 |
+
rules: { swap2: false, noOverlines: false, boardSize: 15 },
|
| 244 |
+
},
|
| 245 |
+
} as any)
|
| 246 |
+
|
| 247 |
+
;(mockEngine as any).camera = { setInitialZoom: vi.fn() }
|
| 248 |
+
;(mockEngine as any).symbols = { customColors: [], drawTowers: [] }
|
| 249 |
+
;(mockEngine as any).updatePlayerConfig = vi.fn()
|
| 250 |
+
;(mockEngine as any).redraw = vi.fn()
|
| 251 |
+
|
| 252 |
+
render(<Game />)
|
| 253 |
+
|
| 254 |
+
await waitFor(() => expect((mockEngine as any).manager).toBeTruthy())
|
| 255 |
+
await screen.findByText(/Caro 1's turn \(Bot\)/)
|
| 256 |
+
})
|
| 257 |
+
|
| 258 |
+
it('renders the Swap2 step guide and shrinks the board fit in local mode', async () => {
|
| 259 |
+
vi.mocked(useParams).mockReturnValue({ roomId: 'local' })
|
| 260 |
+
vi.mocked(useLocation).mockReturnValue({
|
| 261 |
+
state: {
|
| 262 |
+
player1Name: 'Caro 1',
|
| 263 |
+
player2Name: 'Caro 2',
|
| 264 |
+
player1Type: 'human',
|
| 265 |
+
player2Type: 'human',
|
| 266 |
+
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 267 |
+
},
|
| 268 |
+
} as any)
|
| 269 |
+
|
| 270 |
+
;(mockEngine as any).camera = { setInitialZoom: vi.fn() }
|
| 271 |
+
;(mockEngine as any).symbols = { customColors: [], drawTowers: [] }
|
| 272 |
+
;(mockEngine as any).updatePlayerConfig = vi.fn()
|
| 273 |
+
;(mockEngine as any).redraw = vi.fn()
|
| 274 |
+
|
| 275 |
+
render(<Game />)
|
| 276 |
+
|
| 277 |
+
const guide = await screen.findByLabelText('Swap2 steps')
|
| 278 |
+
expect(guide).toBeInTheDocument()
|
| 279 |
+
expect(screen.getByText('1. Place three stones')).toHaveStyle(`color: ${mockTheme.player1Str}`)
|
| 280 |
+
expect(screen.getByText('2. Swap2 Choices')).toBeInTheDocument()
|
| 281 |
+
expect(screen.getByText('3. Continue the game')).toBeInTheDocument()
|
| 282 |
+
expect((mockEngine as any).camera.setInitialZoom).toHaveBeenCalledWith(15, 0.66)
|
| 283 |
+
})
|
| 284 |
+
|
| 285 |
+
it('hides the Swap2 guide after the first normal move following the choice', async () => {
|
| 286 |
+
vi.mocked(useParams).mockReturnValue({ roomId: 'local' })
|
| 287 |
+
vi.mocked(useLocation).mockReturnValue({
|
| 288 |
+
state: {
|
| 289 |
+
player1Name: 'Caro 1',
|
| 290 |
+
player2Name: 'Caro 2',
|
| 291 |
+
player1Type: 'human',
|
| 292 |
+
player2Type: 'human',
|
| 293 |
+
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 294 |
+
},
|
| 295 |
+
} as any)
|
| 296 |
+
|
| 297 |
+
;(mockEngine as any).camera = { setInitialZoom: vi.fn() }
|
| 298 |
+
;(mockEngine as any).symbols = { customColors: [], drawTowers: [] }
|
| 299 |
+
;(mockEngine as any).updatePlayerConfig = vi.fn()
|
| 300 |
+
;(mockEngine as any).redraw = vi.fn()
|
| 301 |
+
|
| 302 |
+
render(<Game />)
|
| 303 |
+
|
| 304 |
+
await screen.findByLabelText('Swap2 steps')
|
| 305 |
+
act(() => {
|
| 306 |
+
;(mockEngine as any).manager.placeMove(7, 7)
|
| 307 |
+
;(mockEngine as any).manager.placeMove(8, 7)
|
| 308 |
+
;(mockEngine as any).manager.placeMove(9, 7)
|
| 309 |
+
;(mockEngine as any).manager.chooseSwap(false)
|
| 310 |
+
;(mockEngine as any).manager.placeMove(10, 7)
|
| 311 |
+
;(mockEngine as any).onStateChanged?.()
|
| 312 |
+
})
|
| 313 |
+
|
| 314 |
+
await waitFor(() => expect(screen.queryByLabelText('Swap2 steps')).not.toBeInTheDocument())
|
| 315 |
+
})
|
| 316 |
+
|
| 317 |
+
it('shows + New Bot while waiting and starts a local bot match', async () => {
|
| 318 |
+
vi.mocked(useParams).mockReturnValue({ roomId: 'waiting-room' })
|
| 319 |
+
vi.mocked(useLocation).mockReturnValue({ state: null } as any)
|
| 320 |
+
mockEngine.status = 'waiting'
|
| 321 |
+
mockEngine.players = ['Caro 1']
|
| 322 |
+
|
| 323 |
+
const view = render(<Game />)
|
| 324 |
+
|
| 325 |
+
const newBot = await view.findByText('+ New Bot')
|
| 326 |
+
fireEvent.click(newBot)
|
| 327 |
+
|
| 328 |
+
expect(mockNavigate).toHaveBeenCalledWith('/play/local', {
|
| 329 |
+
state: {
|
| 330 |
+
player1Name: 'Caro 1',
|
| 331 |
+
player2Name: 'Bot 2',
|
| 332 |
+
player1Symbol: 'X',
|
| 333 |
+
player2Symbol: 'O',
|
| 334 |
+
player1Type: 'human',
|
| 335 |
+
player2Type: 'bot',
|
| 336 |
+
botProfile: 'balanced',
|
| 337 |
+
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 338 |
+
},
|
| 339 |
+
})
|
| 340 |
+
})
|
| 341 |
+
|
| 342 |
+
it('calls the server bot endpoint before placing a local bot move', async () => {
|
| 343 |
+
vi.mocked(useParams).mockReturnValue({ roomId: 'local' })
|
| 344 |
+
vi.mocked(useLocation).mockReturnValue({
|
| 345 |
+
state: {
|
| 346 |
+
player1Name: 'Bot 1',
|
| 347 |
+
player2Name: 'Caro 2',
|
| 348 |
+
player1Type: 'bot',
|
| 349 |
+
player2Type: 'human',
|
| 350 |
+
botProfile: 'expert',
|
| 351 |
+
rules: { swap2: false, noOverlines: false, boardSize: 15 },
|
| 352 |
+
},
|
| 353 |
+
} as any)
|
| 354 |
+
|
| 355 |
+
const fetchMock = vi.fn(async () => ({
|
| 356 |
+
ok: true,
|
| 357 |
+
json: async () => ({ move: { x: 7, y: 7 }, source: 'alpha_beta' }),
|
| 358 |
+
}))
|
| 359 |
+
vi.stubGlobal('fetch', fetchMock)
|
| 360 |
+
vi.useFakeTimers()
|
| 361 |
+
|
| 362 |
+
;(mockEngine as any).camera = { setInitialZoom: vi.fn() }
|
| 363 |
+
;(mockEngine as any).symbols = { customColors: [], drawTowers: [] }
|
| 364 |
+
;(mockEngine as any).updatePlayerConfig = vi.fn()
|
| 365 |
+
;(mockEngine as any).redraw = vi.fn()
|
| 366 |
+
|
| 367 |
+
render(<Game />)
|
| 368 |
+
await act(async () => {
|
| 369 |
+
vi.advanceTimersByTime(0)
|
| 370 |
+
await Promise.resolve()
|
| 371 |
+
})
|
| 372 |
+
expect((mockEngine as any).manager).toBeTruthy()
|
| 373 |
+
fetchMock.mockClear()
|
| 374 |
+
await act(async () => {
|
| 375 |
+
;(mockEngine as any).status = 'active'
|
| 376 |
+
;(mockEngine as any).currentPlayer = 0
|
| 377 |
+
;(mockEngine as any).onStateChanged?.()
|
| 378 |
+
vi.advanceTimersByTime(221)
|
| 379 |
+
await Promise.resolve()
|
| 380 |
+
await Promise.resolve()
|
| 381 |
+
})
|
| 382 |
+
expect(fetchMock).toHaveBeenCalledWith('/api/bot/move', expect.objectContaining({
|
| 383 |
+
method: 'POST',
|
| 384 |
+
headers: { 'content-type': 'application/json' },
|
| 385 |
+
}))
|
| 386 |
+
|
| 387 |
+
const [, options] = fetchMock.mock.calls[0]
|
| 388 |
+
const body = JSON.parse((options as RequestInit).body as string)
|
| 389 |
+
expect(body.currentPlayer).toBe(0)
|
| 390 |
+
expect(body.profile).toBe('expert')
|
| 391 |
+
expect(body.rules).toEqual({ swap2: false, noOverlines: false, boardSize: 15 })
|
| 392 |
+
expect(mockEngine.placeLocalMove).toHaveBeenCalledWith(7, 7)
|
| 393 |
+
})
|
| 394 |
+
|
| 395 |
+
it('swaps player symbols and colors next to the player cards when local swap is applied', async () => {
|
| 396 |
+
vi.mocked(useParams).mockReturnValue({ roomId: 'local' })
|
| 397 |
+
vi.mocked(useLocation).mockReturnValue({ state: { player1Name: 'Caro 1', player2Name: 'Caro 2' } } as any)
|
| 398 |
+
|
| 399 |
+
// Setup mock properties for local mode
|
| 400 |
+
;(mockEngine as any).camera = { setInitialZoom: vi.fn() }
|
| 401 |
+
;(mockEngine as any).symbols = { customColors: [], drawTowers: [] }
|
| 402 |
+
;(mockEngine as any).updatePlayerConfig = vi.fn()
|
| 403 |
+
;(mockEngine as any).redraw = vi.fn()
|
| 404 |
+
|
| 405 |
+
render(<Game />)
|
| 406 |
+
|
| 407 |
+
// Wait for engine to initialize and edit buttons to render
|
| 408 |
+
await screen.findByTitle('Edit Player 1')
|
| 409 |
+
|
| 410 |
+
// Simulate engine swap values (names stay same, symbols swap)
|
| 411 |
+
;(mockEngine as any).players = ['Caro 1', 'Caro 2']
|
| 412 |
+
;(mockEngine as any).playerSymbols = ['O', 'X']
|
| 413 |
+
|
| 414 |
+
// Simulate swap applied
|
| 415 |
+
act(() => {
|
| 416 |
+
;(mockEngine as any).onSwapApplied?.()
|
| 417 |
+
})
|
| 418 |
+
|
| 419 |
+
// Now Player 1 Card should display "Caro 1" and "O"
|
| 420 |
+
const card1 = await screen.findByTitle('Edit Player 1')
|
| 421 |
+
expect(within(card1).getByText(/Caro 1/)).toBeInTheDocument()
|
| 422 |
+
expect(within(card1).getByText('O')).toBeInTheDocument()
|
| 423 |
+
|
| 424 |
+
// Now Player 2 Card should display "Caro 2" and "X"
|
| 425 |
+
const card2 = await screen.findByTitle('Edit Player 2')
|
| 426 |
+
expect(within(card2).getByText(/Caro 2/)).toBeInTheDocument()
|
| 427 |
+
expect(within(card2).getByText('X')).toBeInTheDocument()
|
| 428 |
+
})
|
| 429 |
+
|
| 430 |
+
it('does not swap player colors or tower settings when local swap is applied with swapped=false', async () => {
|
| 431 |
+
vi.mocked(useParams).mockReturnValue({ roomId: 'local' })
|
| 432 |
+
vi.mocked(useLocation).mockReturnValue({ state: { player1Name: 'Caro 1', player2Name: 'Caro 2' } } as any)
|
| 433 |
+
|
| 434 |
+
// Setup mock properties for local mode
|
| 435 |
+
;(mockEngine as any).camera = { setInitialZoom: vi.fn() }
|
| 436 |
+
;(mockEngine as any).symbols = { customColors: [], drawTowers: [] }
|
| 437 |
+
;(mockEngine as any).updatePlayerConfig = vi.fn()
|
| 438 |
+
;(mockEngine as any).redraw = vi.fn()
|
| 439 |
+
|
| 440 |
+
render(<Game />)
|
| 441 |
+
|
| 442 |
+
// Wait for engine to initialize and edit buttons to render
|
| 443 |
+
await screen.findByTitle('Edit Player 1')
|
| 444 |
+
|
| 445 |
+
// Reset calls on mock functions
|
| 446 |
+
vi.mocked(setPlayerDrawTower).mockClear()
|
| 447 |
+
vi.mocked(setPlayer2DrawTower).mockClear()
|
| 448 |
+
|
| 449 |
+
// Simulate engine values (names and symbols stay same)
|
| 450 |
+
;(mockEngine as any).players = ['Caro 1', 'Caro 2']
|
| 451 |
+
;(mockEngine as any).playerSymbols = ['X', 'O']
|
| 452 |
+
|
| 453 |
+
// Simulate swap applied with false (stay)
|
| 454 |
+
act(() => {
|
| 455 |
+
;(mockEngine as any).onSwapApplied?.(false)
|
| 456 |
+
})
|
| 457 |
+
|
| 458 |
+
// Assert that tower settings were not updated (since no swap happened)
|
| 459 |
+
expect(setPlayerDrawTower).not.toHaveBeenCalled()
|
| 460 |
+
expect(setPlayer2DrawTower).not.toHaveBeenCalled()
|
| 461 |
+
|
| 462 |
+
// Now Player 1 Card should still display "Caro 1" and "X"
|
| 463 |
+
const card1 = await screen.findByTitle('Edit Player 1')
|
| 464 |
+
expect(within(card1).getByText(/Caro 1/)).toBeInTheDocument()
|
| 465 |
+
expect(within(card1).getByText('X')).toBeInTheDocument()
|
| 466 |
+
|
| 467 |
+
// Now Player 2 Card should still display "Caro 2" and "O"
|
| 468 |
+
const card2 = await screen.findByTitle('Edit Player 2')
|
| 469 |
+
expect(within(card2).getByText(/Caro 2/)).toBeInTheDocument()
|
| 470 |
+
expect(within(card2).getByText('O')).toBeInTheDocument()
|
| 471 |
+
})
|
| 472 |
+
})
|
client/src/__tests__/Game.test.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
| 2 |
+
import { render, screen, cleanup } from '@testing-library/react'
|
| 3 |
+
import Game from '../pages/Game.tsx'
|
| 4 |
+
|
| 5 |
+
const { mockTheme } = vi.hoisted(() => ({
|
| 6 |
+
mockTheme: {
|
| 7 |
+
bgStr: '#0d1117',
|
| 8 |
+
text: '#e6edf3',
|
| 9 |
+
textMuted: '#8b949e',
|
| 10 |
+
bgPanel: 'rgba(22,27,34,0.92)',
|
| 11 |
+
border: '#21262d',
|
| 12 |
+
player1Str: '#58a6ff',
|
| 13 |
+
player2Str: '#f0883e',
|
| 14 |
+
rematchBg: '#238636',
|
| 15 |
+
errorBg: 'rgba(35,10,10,0.92)',
|
| 16 |
+
error: '#f85149',
|
| 17 |
+
playing: '#58a6ff',
|
| 18 |
+
waiting: '#8b949e',
|
| 19 |
+
win: '#3fb950',
|
| 20 |
+
draw: '#d29922',
|
| 21 |
+
bg: '#0d1117',
|
| 22 |
+
},
|
| 23 |
+
}))
|
| 24 |
+
|
| 25 |
+
vi.mock('react-router-dom', () => ({
|
| 26 |
+
useParams: vi.fn(() => ({ roomId: 'test-room' })),
|
| 27 |
+
useNavigate: vi.fn(() => vi.fn()),
|
| 28 |
+
useLocation: vi.fn(() => ({ state: null })),
|
| 29 |
+
}))
|
| 30 |
+
|
| 31 |
+
vi.mock('../services/socket.ts', () => ({
|
| 32 |
+
sendMessage: vi.fn(),
|
| 33 |
+
onMessage: vi.fn(() => vi.fn()),
|
| 34 |
+
onReconnect: vi.fn(() => vi.fn()),
|
| 35 |
+
onConnectionChange: vi.fn(() => vi.fn()),
|
| 36 |
+
getConnectionStatus: vi.fn(() => 'connected'),
|
| 37 |
+
}))
|
| 38 |
+
|
| 39 |
+
vi.mock('../theme.ts', () => ({
|
| 40 |
+
theme: mockTheme,
|
| 41 |
+
THEMES: { midnight: { label: 'Midnight', emoji: '🌙' } },
|
| 42 |
+
useThemeMode: vi.fn(() => ['midnight', vi.fn()]),
|
| 43 |
+
getThemeMode: vi.fn(() => 'midnight'),
|
| 44 |
+
getPlayerName: vi.fn(() => 'Caro 1'),
|
| 45 |
+
setPlayerName: vi.fn(),
|
| 46 |
+
getPlayer2Name: vi.fn(() => 'Caro 2'),
|
| 47 |
+
setPlayer2Name: vi.fn(),
|
| 48 |
+
getPlayerSymbol: vi.fn(() => 'X'),
|
| 49 |
+
setPlayerSymbol: vi.fn(),
|
| 50 |
+
getPlayer1Symbol: vi.fn(() => 'X'),
|
| 51 |
+
setPlayer1Symbol: vi.fn(),
|
| 52 |
+
getPlayer2Symbol: vi.fn(() => 'O'),
|
| 53 |
+
setPlayer2Symbol: vi.fn(),
|
| 54 |
+
getPlayerColor: vi.fn(() => null),
|
| 55 |
+
setPlayerColor: vi.fn(),
|
| 56 |
+
getPlayer2Color: vi.fn(() => null),
|
| 57 |
+
setPlayer2Color: vi.fn(),
|
| 58 |
+
getPlayerDrawTower: vi.fn(() => true),
|
| 59 |
+
setPlayerDrawTower: vi.fn(),
|
| 60 |
+
getPlayer2DrawTower: vi.fn(() => true),
|
| 61 |
+
setPlayer2DrawTower: vi.fn(),
|
| 62 |
+
}))
|
| 63 |
+
|
| 64 |
+
vi.mock('../components/ThemeSelector.tsx', () => ({
|
| 65 |
+
default: () => null,
|
| 66 |
+
}))
|
| 67 |
+
|
| 68 |
+
vi.mock('../components/SymbolPicker.tsx', () => ({
|
| 69 |
+
default: () => null,
|
| 70 |
+
}))
|
| 71 |
+
|
| 72 |
+
vi.mock('../components/GameCanvas.tsx', () => ({
|
| 73 |
+
default: () => <div data-testid="game-canvas" />,
|
| 74 |
+
}))
|
| 75 |
+
|
| 76 |
+
afterEach(cleanup)
|
| 77 |
+
|
| 78 |
+
describe('Game page (basic)', () => {
|
| 79 |
+
it('renders root container with 100dvh', () => {
|
| 80 |
+
render(<Game />)
|
| 81 |
+
const container = screen.getByTestId('game-canvas').parentElement!
|
| 82 |
+
expect(container).toHaveStyle('height: 100dvh')
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
it('renders Menu button with 44px touch target', () => {
|
| 86 |
+
render(<Game />)
|
| 87 |
+
const menuBtn = screen.getByText('← Menu')
|
| 88 |
+
expect(menuBtn).toHaveStyle('minWidth: 44px')
|
| 89 |
+
expect(menuBtn).toHaveStyle('minHeight: 44px')
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
it('renders +New Player button with 44px touch target', () => {
|
| 93 |
+
render(<Game />)
|
| 94 |
+
const newPlayerBtn = screen.getByText('+ New Player')
|
| 95 |
+
expect(newPlayerBtn).toHaveStyle('minWidth: 44px')
|
| 96 |
+
expect(newPlayerBtn).toHaveStyle('minHeight: 44px')
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
it('renders theme toggle with 44px touch target', () => {
|
| 100 |
+
render(<Game />)
|
| 101 |
+
const themeBtn = screen.getByText('🌙')
|
| 102 |
+
expect(themeBtn).toHaveStyle('minWidth: 44px')
|
| 103 |
+
expect(themeBtn).toHaveStyle('minHeight: 44px')
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
it('renders symbol button with 44px touch target', () => {
|
| 107 |
+
render(<Game />)
|
| 108 |
+
const symBtn = screen.getByText('X')
|
| 109 |
+
expect(symBtn).toHaveStyle('minWidth: 44px')
|
| 110 |
+
expect(symBtn).toHaveStyle('minHeight: 44px')
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
it('renders mute button with 44px touch target', () => {
|
| 114 |
+
render(<Game />)
|
| 115 |
+
const muteBtn = screen.getByText('🔊')
|
| 116 |
+
expect(muteBtn).toHaveStyle('minWidth: 44px')
|
| 117 |
+
expect(muteBtn).toHaveStyle('minHeight: 44px')
|
| 118 |
+
})
|
| 119 |
+
})
|
client/src/__tests__/GeneratedReplay.test.tsx
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
| 2 |
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
| 3 |
+
import GeneratedReplay from '../pages/GeneratedReplay.tsx'
|
| 4 |
+
|
| 5 |
+
const { mockNavigate, mockParams } = vi.hoisted(() => ({
|
| 6 |
+
mockNavigate: vi.fn(),
|
| 7 |
+
mockParams: { value: {} as { replayId?: string } },
|
| 8 |
+
}))
|
| 9 |
+
|
| 10 |
+
const { onMsgCb, sendMessage } = vi.hoisted(() => {
|
| 11 |
+
let msgCb: ((msg: any) => void) | null = null
|
| 12 |
+
return {
|
| 13 |
+
onMsgCb: { get value() { return msgCb }, set value(v) { msgCb = v } },
|
| 14 |
+
sendMessage: vi.fn(),
|
| 15 |
+
}
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
vi.mock('react-router-dom', () => ({
|
| 19 |
+
useNavigate: () => mockNavigate,
|
| 20 |
+
useParams: () => mockParams.value,
|
| 21 |
+
}))
|
| 22 |
+
|
| 23 |
+
vi.mock('../services/socket.ts', () => ({
|
| 24 |
+
sendMessage: vi.fn((...args: unknown[]) => (sendMessage as any)(...args)),
|
| 25 |
+
onMessage: vi.fn((cb: any) => { (onMsgCb as any).value = cb; return vi.fn() }),
|
| 26 |
+
}))
|
| 27 |
+
|
| 28 |
+
vi.mock('../theme.ts', () => ({
|
| 29 |
+
theme: {
|
| 30 |
+
bgStr: '#0d1117',
|
| 31 |
+
text: '#e6edf3',
|
| 32 |
+
textMuted: '#8b949e',
|
| 33 |
+
bgPanel: 'rgba(22,27,34,0.92)',
|
| 34 |
+
border: '#21262d',
|
| 35 |
+
player1Str: '#58a6ff',
|
| 36 |
+
player2Str: '#f0883e',
|
| 37 |
+
error: '#f85149',
|
| 38 |
+
win: '#3fb950',
|
| 39 |
+
draw: '#d29922',
|
| 40 |
+
playing: '#58a6ff',
|
| 41 |
+
bg: '#0d1117',
|
| 42 |
+
},
|
| 43 |
+
}))
|
| 44 |
+
|
| 45 |
+
afterEach(cleanup)
|
| 46 |
+
|
| 47 |
+
beforeEach(() => {
|
| 48 |
+
vi.clearAllMocks()
|
| 49 |
+
onMsgCb.value = null
|
| 50 |
+
mockParams.value = {}
|
| 51 |
+
})
|
| 52 |
+
|
| 53 |
+
const catalog = {
|
| 54 |
+
games: [{
|
| 55 |
+
source: 'selfplay',
|
| 56 |
+
id: 'selfplay:test-1',
|
| 57 |
+
label: 'test-1',
|
| 58 |
+
batchId: 'selfplay',
|
| 59 |
+
batchLabel: 'Selfplay',
|
| 60 |
+
player1: 'mcts_weak',
|
| 61 |
+
player2: 'random_balanced',
|
| 62 |
+
winner: 0,
|
| 63 |
+
result: 'win',
|
| 64 |
+
moveCount: 5,
|
| 65 |
+
durationMs: 12,
|
| 66 |
+
mctsTargetCount: 1,
|
| 67 |
+
createdAt: '2026-01-01T00:00:00.000Z',
|
| 68 |
+
}],
|
| 69 |
+
batches: [{
|
| 70 |
+
id: 'selfplay',
|
| 71 |
+
label: 'Selfplay',
|
| 72 |
+
file: 'selfplay.jsonl',
|
| 73 |
+
source: 'selfplay',
|
| 74 |
+
gameCount: 1,
|
| 75 |
+
}],
|
| 76 |
+
presets: {
|
| 77 |
+
random: 'selfplay:test-1',
|
| 78 |
+
fastest: 'selfplay:test-1',
|
| 79 |
+
slowest: 'selfplay:test-1',
|
| 80 |
+
shortest: 'selfplay:test-1',
|
| 81 |
+
longest: 'selfplay:test-1',
|
| 82 |
+
mcts: 'selfplay:test-1',
|
| 83 |
+
draw: null,
|
| 84 |
+
player1_win: 'selfplay:test-1',
|
| 85 |
+
player2_win: null,
|
| 86 |
+
},
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const replay = {
|
| 90 |
+
...catalog.games[0],
|
| 91 |
+
rules: { boardSize: 15, swap2: true, noOverlines: true },
|
| 92 |
+
moves: [
|
| 93 |
+
{ moveNumber: 1, x: 0, y: 0, player: 0, symbol: 'X' },
|
| 94 |
+
{ moveNumber: 2, x: 0, y: 1, player: 1, symbol: 'O' },
|
| 95 |
+
{ moveNumber: 3, x: 1, y: 0, player: 0, symbol: 'X' },
|
| 96 |
+
{ moveNumber: 4, x: 1, y: 1, player: 1, symbol: 'O' },
|
| 97 |
+
{ moveNumber: 5, x: 2, y: 0, player: 0, symbol: 'X' },
|
| 98 |
+
],
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
describe('GeneratedReplay page', () => {
|
| 102 |
+
it('loads catalog and generated replay over socket', async () => {
|
| 103 |
+
render(<GeneratedReplay />)
|
| 104 |
+
|
| 105 |
+
expect(sendMessage).toHaveBeenCalledWith('GET_GENERATED_REPLAYS')
|
| 106 |
+
onMsgCb.value?.({ type: 'GENERATED_REPLAYS_DATA', payload: catalog })
|
| 107 |
+
|
| 108 |
+
await waitFor(() => expect(sendMessage).toHaveBeenCalledWith('GET_GENERATED_REPLAY', { id: 'selfplay:test-1' }))
|
| 109 |
+
onMsgCb.value?.({ type: 'GENERATED_REPLAY_DATA', payload: { replay } })
|
| 110 |
+
|
| 111 |
+
expect(await screen.findByText('Generated Replays')).toBeInTheDocument()
|
| 112 |
+
expect(screen.getByText('test-1')).toBeInTheDocument()
|
| 113 |
+
expect(screen.getByText(/mcts_weak vs random_balanced/)).toBeInTheDocument()
|
| 114 |
+
expect(screen.getByText(/Selfplay · 1 of 1 games/)).toBeInTheDocument()
|
| 115 |
+
expect(screen.getByText('One MCTS')).toBeInTheDocument()
|
| 116 |
+
expect(screen.getAllByTestId('generated-replay-cell')).toHaveLength(225)
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
it('requests the selected batch MCTS replay when the preset is selected', async () => {
|
| 120 |
+
render(<GeneratedReplay />)
|
| 121 |
+
onMsgCb.value?.({ type: 'GENERATED_REPLAYS_DATA', payload: catalog })
|
| 122 |
+
onMsgCb.value?.({ type: 'GENERATED_REPLAY_DATA', payload: { replay } })
|
| 123 |
+
|
| 124 |
+
fireEvent.click(await screen.findByText('One MCTS'))
|
| 125 |
+
|
| 126 |
+
expect(sendMessage).toHaveBeenCalledWith('GET_GENERATED_REPLAY', { id: 'selfplay:test-1' })
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
it('loads a direct replay id from the route instead of the first catalog game', async () => {
|
| 130 |
+
mockParams.value = { replayId: 'selfplay-112323-1' }
|
| 131 |
+
render(<GeneratedReplay />)
|
| 132 |
+
|
| 133 |
+
onMsgCb.value?.({ type: 'GENERATED_REPLAYS_DATA', payload: {
|
| 134 |
+
...catalog,
|
| 135 |
+
games: [
|
| 136 |
+
{ ...catalog.games[0], id: 'selfplay:first-game', label: 'first-game' },
|
| 137 |
+
{ ...catalog.games[0], id: 'selfplay:selfplay-112323-1', label: 'selfplay-112323-1' },
|
| 138 |
+
],
|
| 139 |
+
} })
|
| 140 |
+
|
| 141 |
+
expect(sendMessage).toHaveBeenCalledWith('GET_GENERATED_REPLAY', { id: 'selfplay-112323-1' })
|
| 142 |
+
expect(sendMessage).not.toHaveBeenCalledWith('GET_GENERATED_REPLAY', { id: 'selfplay:first-game' })
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
it('opens a batch modal and loads the first replay from the selected batch', async () => {
|
| 146 |
+
render(<GeneratedReplay />)
|
| 147 |
+
onMsgCb.value?.({ type: 'GENERATED_REPLAYS_DATA', payload: {
|
| 148 |
+
...catalog,
|
| 149 |
+
batches: [
|
| 150 |
+
catalog.batches[0],
|
| 151 |
+
{ id: 'analysis-batch', label: 'Analysis Batch', file: 'analysis-batch.jsonl', source: 'selfplay', gameCount: 1 },
|
| 152 |
+
],
|
| 153 |
+
games: [
|
| 154 |
+
catalog.games[0],
|
| 155 |
+
{
|
| 156 |
+
...catalog.games[0],
|
| 157 |
+
id: 'selfplay:analysis-batch:test-2',
|
| 158 |
+
label: 'test-2',
|
| 159 |
+
batchId: 'analysis-batch',
|
| 160 |
+
batchLabel: 'Analysis Batch',
|
| 161 |
+
},
|
| 162 |
+
],
|
| 163 |
+
} })
|
| 164 |
+
onMsgCb.value?.({ type: 'GENERATED_REPLAY_DATA', payload: { replay } })
|
| 165 |
+
|
| 166 |
+
fireEvent.click(await screen.findByText('Batch'))
|
| 167 |
+
expect(screen.getByRole('dialog', { name: 'Select replay batch' })).toBeInTheDocument()
|
| 168 |
+
fireEvent.click(screen.getByText('Analysis Batch'))
|
| 169 |
+
|
| 170 |
+
expect(sendMessage).toHaveBeenCalledWith('GET_GENERATED_REPLAY', { id: 'selfplay:analysis-batch:test-2' })
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
it('keeps preset buttons scoped to the chosen batch', async () => {
|
| 174 |
+
render(<GeneratedReplay />)
|
| 175 |
+
onMsgCb.value?.({ type: 'GENERATED_REPLAYS_DATA', payload: {
|
| 176 |
+
...catalog,
|
| 177 |
+
batches: [
|
| 178 |
+
catalog.batches[0],
|
| 179 |
+
{ id: 'analysis-batch', label: 'Analysis Batch', file: 'analysis-batch.jsonl', source: 'selfplay', gameCount: 2 },
|
| 180 |
+
],
|
| 181 |
+
games: [
|
| 182 |
+
catalog.games[0],
|
| 183 |
+
{
|
| 184 |
+
...catalog.games[0],
|
| 185 |
+
id: 'selfplay:analysis-batch:p1',
|
| 186 |
+
label: 'p1',
|
| 187 |
+
batchId: 'analysis-batch',
|
| 188 |
+
batchLabel: 'Analysis Batch',
|
| 189 |
+
winner: 0,
|
| 190 |
+
durationMs: 80,
|
| 191 |
+
mctsTargetCount: 0,
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
...catalog.games[0],
|
| 195 |
+
id: 'selfplay:analysis-batch:p2',
|
| 196 |
+
label: 'p2',
|
| 197 |
+
batchId: 'analysis-batch',
|
| 198 |
+
batchLabel: 'Analysis Batch',
|
| 199 |
+
winner: 1,
|
| 200 |
+
durationMs: 10,
|
| 201 |
+
mctsTargetCount: 1,
|
| 202 |
+
},
|
| 203 |
+
],
|
| 204 |
+
} })
|
| 205 |
+
onMsgCb.value?.({ type: 'GENERATED_REPLAY_DATA', payload: { replay } })
|
| 206 |
+
|
| 207 |
+
fireEvent.click(await screen.findByText('Batch'))
|
| 208 |
+
fireEvent.click(screen.getByText('Analysis Batch'))
|
| 209 |
+
fireEvent.click(screen.getByText('P2 Win'))
|
| 210 |
+
|
| 211 |
+
expect(sendMessage).toHaveBeenCalledWith('GET_GENERATED_REPLAY', { id: 'selfplay:analysis-batch:p2' })
|
| 212 |
+
})
|
| 213 |
+
})
|
client/src/__tests__/Landing.test.tsx
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
| 2 |
+
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
|
| 3 |
+
import Landing from '../pages/Landing.tsx'
|
| 4 |
+
|
| 5 |
+
const { mockTheme, mockNavigate } = vi.hoisted(() => ({
|
| 6 |
+
mockTheme: {
|
| 7 |
+
bgStr: '#0d1117',
|
| 8 |
+
text: '#e6edf3',
|
| 9 |
+
textMuted: '#8b949e',
|
| 10 |
+
bgPanel: 'rgba(22,27,34,0.92)',
|
| 11 |
+
border: '#21262d',
|
| 12 |
+
player1Str: '#58a6ff',
|
| 13 |
+
player2Str: '#f0883e',
|
| 14 |
+
},
|
| 15 |
+
mockNavigate: vi.fn(),
|
| 16 |
+
}))
|
| 17 |
+
|
| 18 |
+
vi.mock('react-router-dom', () => ({
|
| 19 |
+
useNavigate: () => mockNavigate,
|
| 20 |
+
}))
|
| 21 |
+
|
| 22 |
+
const { onMsgCb, onConnCb, sendMessage, getConnectionStatus, onConnectionChange } = vi.hoisted(() => {
|
| 23 |
+
let msgCb: ((msg: any) => void) | null = null
|
| 24 |
+
let connCb: ((status: string) => void) | null = null
|
| 25 |
+
return {
|
| 26 |
+
onMsgCb: { get value() { return msgCb }, set value(v) { msgCb = v } },
|
| 27 |
+
onConnCb: { get value() { return connCb }, set value(v) { connCb = v } },
|
| 28 |
+
sendMessage: vi.fn(),
|
| 29 |
+
getConnectionStatus: vi.fn(() => 'connected'),
|
| 30 |
+
onConnectionChange: vi.fn((cb) => { connCb = cb; return vi.fn() }),
|
| 31 |
+
}
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
vi.mock('../services/socket.ts', () => ({
|
| 35 |
+
sendMessage: vi.fn((...args: unknown[]) => (sendMessage as any)(...args)),
|
| 36 |
+
onMessage: vi.fn((cb: any) => { (onMsgCb as any).value = cb; return vi.fn() }),
|
| 37 |
+
getConnectionStatus: vi.fn(() => (getConnectionStatus as any)()),
|
| 38 |
+
onConnectionChange: vi.fn((cb: any) => { (onConnCb as any).value = cb; return vi.fn() }),
|
| 39 |
+
}))
|
| 40 |
+
|
| 41 |
+
vi.mock('../theme.ts', () => ({
|
| 42 |
+
theme: mockTheme,
|
| 43 |
+
THEMES: { midnight: { label: 'Midnight', emoji: '🌙' } },
|
| 44 |
+
useThemeMode: vi.fn(() => ['midnight', vi.fn()]),
|
| 45 |
+
getThemeMode: vi.fn(() => 'midnight'),
|
| 46 |
+
getPlayerName: vi.fn(() => ''),
|
| 47 |
+
setPlayerName: vi.fn(),
|
| 48 |
+
getPlayer2Name: vi.fn(() => ''),
|
| 49 |
+
setPlayer2Name: vi.fn(),
|
| 50 |
+
getPlayerSymbol: vi.fn(() => 'X'),
|
| 51 |
+
setPlayerSymbol: vi.fn(),
|
| 52 |
+
}))
|
| 53 |
+
|
| 54 |
+
vi.mock('../components/ThemeSelector.tsx', () => ({
|
| 55 |
+
default: () => null,
|
| 56 |
+
}))
|
| 57 |
+
|
| 58 |
+
vi.mock('../components/SymbolPicker.tsx', () => ({
|
| 59 |
+
default: () => null,
|
| 60 |
+
}))
|
| 61 |
+
|
| 62 |
+
afterEach(cleanup)
|
| 63 |
+
beforeEach(() => {
|
| 64 |
+
localStorage.clear()
|
| 65 |
+
vi.clearAllMocks()
|
| 66 |
+
onMsgCb.value = null
|
| 67 |
+
onConnCb.value = null
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
describe('Landing page', () => {
|
| 71 |
+
it('renders the title', () => {
|
| 72 |
+
render(<Landing />)
|
| 73 |
+
expect(screen.getByText('Caro5')).toBeInTheDocument()
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
it('renders the subtitle', () => {
|
| 77 |
+
render(<Landing />)
|
| 78 |
+
expect(screen.getByText('Infinite tactical board arena')).toBeInTheDocument()
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
it('renders only Player 1 name input initially', () => {
|
| 82 |
+
render(<Landing />)
|
| 83 |
+
const inputs = screen.getAllByPlaceholderText('Enter name')
|
| 84 |
+
expect(inputs).toHaveLength(1)
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
it('renders the start button', () => {
|
| 88 |
+
render(<Landing />)
|
| 89 |
+
expect(screen.getByText('Start New Game')).toBeInTheDocument()
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
it('renders theme toggle button with 44px touch targets', () => {
|
| 93 |
+
render(<Landing />)
|
| 94 |
+
const btn = screen.getByText('🌙')
|
| 95 |
+
expect(btn).toBeInTheDocument()
|
| 96 |
+
expect(btn).toHaveStyle('minWidth: 44px')
|
| 97 |
+
expect(btn).toHaveStyle('minHeight: 44px')
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
it('has 100dvh height on container', () => {
|
| 101 |
+
render(<Landing />)
|
| 102 |
+
const title = screen.getByText('Caro5')
|
| 103 |
+
const container = title.parentElement!
|
| 104 |
+
expect(container).toHaveStyle('height: 100dvh')
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
it('uses clamp() for the title font size', () => {
|
| 108 |
+
render(<Landing />)
|
| 109 |
+
const title = screen.getByText('Caro5')
|
| 110 |
+
expect(title).toHaveStyle('fontSize: clamp(2rem, 8vw, 3rem)')
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
it('renders symbol picker button with current symbol', () => {
|
| 114 |
+
render(<Landing />)
|
| 115 |
+
const btn = screen.getByText('Symbol: X')
|
| 116 |
+
expect(btn).toBeInTheDocument()
|
| 117 |
+
expect(btn).toHaveStyle('minWidth: 44px')
|
| 118 |
+
expect(btn).toHaveStyle('minHeight: 44px')
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
it('renders Player 1 label but not Player 2 initially', () => {
|
| 122 |
+
render(<Landing />)
|
| 123 |
+
expect(screen.getByText('Player 1')).toBeInTheDocument()
|
| 124 |
+
expect(screen.queryByText('Player 2')).not.toBeInTheDocument()
|
| 125 |
+
})
|
| 126 |
+
|
| 127 |
+
it('renders the local game button', () => {
|
| 128 |
+
render(<Landing />)
|
| 129 |
+
expect(screen.getByText('Caro vs Caro (same tab)')).toBeInTheDocument()
|
| 130 |
+
})
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
describe('Local game button', () => {
|
| 134 |
+
it('navigates to /local-setup on click with rules in state', () => {
|
| 135 |
+
render(<Landing />)
|
| 136 |
+
fireEvent.click(screen.getByText('Caro vs Caro (same tab)'))
|
| 137 |
+
expect(mockNavigate).toHaveBeenCalledTimes(1)
|
| 138 |
+
expect(mockNavigate).toHaveBeenCalledWith('/local-setup', {
|
| 139 |
+
state: { rules: { swap2: true, noOverlines: true, boardSize: 15 } }
|
| 140 |
+
})
|
| 141 |
+
})
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
describe('Create-phase state machine', () => {
|
| 145 |
+
it('starts in idle phase', () => {
|
| 146 |
+
render(<Landing />)
|
| 147 |
+
expect(screen.getByText('Start New Game')).toBeInTheDocument()
|
| 148 |
+
expect(screen.queryByText('Creating...')).not.toBeInTheDocument()
|
| 149 |
+
})
|
| 150 |
+
|
| 151 |
+
it('transitions to creating phase when clicked (already connected)', () => {
|
| 152 |
+
render(<Landing />)
|
| 153 |
+
const btn = screen.getByText('Start New Game')
|
| 154 |
+
fireEvent.click(btn)
|
| 155 |
+
expect(screen.getByText('Creating...')).toBeInTheDocument()
|
| 156 |
+
expect(screen.getByText('Creating room...')).toBeInTheDocument()
|
| 157 |
+
expect(btn).toBeDisabled()
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
it('shows progress bar during create phase', () => {
|
| 161 |
+
render(<Landing />)
|
| 162 |
+
fireEvent.click(screen.getByText('Start New Game'))
|
| 163 |
+
expect(screen.getByText('Creating room...')).toBeInTheDocument()
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
it('sends CREATE_ROOM on click', () => {
|
| 167 |
+
render(<Landing />)
|
| 168 |
+
fireEvent.click(screen.getByText('Start New Game'))
|
| 169 |
+
expect(sendMessage).toHaveBeenCalledWith('CREATE_ROOM', {
|
| 170 |
+
playerName: 'Player',
|
| 171 |
+
symbol: 'X',
|
| 172 |
+
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 173 |
+
})
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
it('transitions to navigating when ROOM_CREATED received', async () => {
|
| 177 |
+
render(<Landing />)
|
| 178 |
+
fireEvent.click(screen.getByText('Start New Game'))
|
| 179 |
+
expect(onMsgCb.value).toBeDefined()
|
| 180 |
+
onMsgCb.value({ type: 'ROOM_CREATED', payload: { roomId: 'abc123' } })
|
| 181 |
+
await screen.findByText('Starting game...')
|
| 182 |
+
})
|
| 183 |
+
|
| 184 |
+
it('navigates to room on ROOM_CREATED message', () => {
|
| 185 |
+
render(<Landing />)
|
| 186 |
+
fireEvent.click(screen.getByText('Start New Game'))
|
| 187 |
+
onMsgCb.value({ type: 'ROOM_CREATED', payload: { roomId: 'abc123' } })
|
| 188 |
+
expect(mockNavigate).toHaveBeenCalledWith('/play/abc123')
|
| 189 |
+
})
|
| 190 |
+
|
| 191 |
+
it('transitions from connecting to creating when connection established', async () => {
|
| 192 |
+
getConnectionStatus.mockReturnValue('disconnected')
|
| 193 |
+
render(<Landing />)
|
| 194 |
+
fireEvent.click(screen.getByText('Start New Game'))
|
| 195 |
+
expect(screen.getByText('Connecting to server...')).toBeInTheDocument()
|
| 196 |
+
expect(onConnCb.value).toBeDefined()
|
| 197 |
+
onConnCb.value('connected')
|
| 198 |
+
await screen.findByText('Creating room...')
|
| 199 |
+
})
|
| 200 |
+
})
|
client/src/__tests__/SymbolPicker.test.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
| 2 |
+
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
|
| 3 |
+
import SymbolPicker from '../components/SymbolPicker.tsx'
|
| 4 |
+
|
| 5 |
+
const { mockTheme } = vi.hoisted(() => ({
|
| 6 |
+
mockTheme: {
|
| 7 |
+
bgStr: '#0d1117',
|
| 8 |
+
text: '#e6edf3',
|
| 9 |
+
textMuted: '#8b949e',
|
| 10 |
+
bgPanel: 'rgba(22,27,34,0.92)',
|
| 11 |
+
border: '#21262d',
|
| 12 |
+
player1Str: '#58a6ff',
|
| 13 |
+
},
|
| 14 |
+
}))
|
| 15 |
+
|
| 16 |
+
vi.mock('../theme.ts', () => ({
|
| 17 |
+
theme: mockTheme,
|
| 18 |
+
}))
|
| 19 |
+
|
| 20 |
+
afterEach(cleanup)
|
| 21 |
+
|
| 22 |
+
describe('SymbolPicker', () => {
|
| 23 |
+
it('renders the title', () => {
|
| 24 |
+
render(<SymbolPicker current="X" onSelect={vi.fn()} onClose={vi.fn()} />)
|
| 25 |
+
expect(screen.getByText('Choose Your Symbol')).toBeInTheDocument()
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
it('renders all four category tabs', () => {
|
| 29 |
+
render(<SymbolPicker current="X" onSelect={vi.fn()} onClose={vi.fn()} />)
|
| 30 |
+
expect(screen.getByText('Classic')).toBeInTheDocument()
|
| 31 |
+
expect(screen.getByText('Hearts')).toBeInTheDocument()
|
| 32 |
+
expect(screen.getByText('Emojis')).toBeInTheDocument()
|
| 33 |
+
expect(screen.getByText('Letters')).toBeInTheDocument()
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
it('shows classic symbols by default', () => {
|
| 37 |
+
render(<SymbolPicker current="X" onSelect={vi.fn()} onClose={vi.fn()} />)
|
| 38 |
+
expect(screen.getByText('X')).toBeInTheDocument()
|
| 39 |
+
expect(screen.getByText('O')).toBeInTheDocument()
|
| 40 |
+
expect(screen.getByText('♥')).toBeInTheDocument()
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
it('switches to hearts category when tab clicked', () => {
|
| 44 |
+
render(<SymbolPicker current="X" onSelect={vi.fn()} onClose={vi.fn()} />)
|
| 45 |
+
fireEvent.click(screen.getByText('Hearts'))
|
| 46 |
+
expect(screen.getByText('❤️')).toBeInTheDocument()
|
| 47 |
+
expect(screen.getByText('💙')).toBeInTheDocument()
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
it('switches to emojis category when tab clicked', () => {
|
| 51 |
+
render(<SymbolPicker current="X" onSelect={vi.fn()} onClose={vi.fn()} />)
|
| 52 |
+
fireEvent.click(screen.getByText('Emojis'))
|
| 53 |
+
expect(screen.getByText('😀')).toBeInTheDocument()
|
| 54 |
+
expect(screen.getByText('🚀')).toBeInTheDocument()
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
it('switches to letters category when tab clicked', () => {
|
| 58 |
+
render(<SymbolPicker current="X" onSelect={vi.fn()} onClose={vi.fn()} />)
|
| 59 |
+
fireEvent.click(screen.getByText('Letters'))
|
| 60 |
+
expect(screen.getByText('A')).toBeInTheDocument()
|
| 61 |
+
expect(screen.getByText('Z')).toBeInTheDocument()
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
it('renders symbol buttons with 48x48 size', () => {
|
| 65 |
+
render(<SymbolPicker current="X" onSelect={vi.fn()} onClose={vi.fn()} />)
|
| 66 |
+
const btns = screen.getAllByRole('button')
|
| 67 |
+
const tabLabels = new Set(['Classic', 'Hearts', 'Emojis', 'Letters'])
|
| 68 |
+
for (const btn of btns) {
|
| 69 |
+
if (tabLabels.has(btn.textContent ?? '')) continue
|
| 70 |
+
expect(btn).toHaveStyle('width: 48px')
|
| 71 |
+
expect(btn).toHaveStyle('height: 48px')
|
| 72 |
+
}
|
| 73 |
+
})
|
| 74 |
+
})
|
client/src/__tests__/css.test.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFileSync } from 'node:fs'
|
| 2 |
+
import { resolve } from 'node:path'
|
| 3 |
+
import { describe, it, expect } from 'vitest'
|
| 4 |
+
|
| 5 |
+
const css = readFileSync(resolve(__dirname, '../index.css'), 'utf-8')
|
| 6 |
+
|
| 7 |
+
describe('fadeIn animation', () => {
|
| 8 |
+
it('defines @keyframes fadeIn', () => {
|
| 9 |
+
expect(css).toContain('@keyframes fadeIn')
|
| 10 |
+
})
|
| 11 |
+
})
|
| 12 |
+
|
| 13 |
+
describe('touch target rules', () => {
|
| 14 |
+
it('sets touch-action: manipulation on buttons and inputs', () => {
|
| 15 |
+
expect(css).toContain('touch-action: manipulation')
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
it('disables tap highlight color', () => {
|
| 19 |
+
expect(css).toContain('-webkit-tap-highlight-color: transparent')
|
| 20 |
+
})
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
describe('scroll behavior', () => {
|
| 24 |
+
it('disables overscroll-behavior', () => {
|
| 25 |
+
expect(css).toContain('overscroll-behavior: none')
|
| 26 |
+
})
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
describe('mobile height', () => {
|
| 30 |
+
it('uses -webkit-fill-available as height fallback', () => {
|
| 31 |
+
expect(css).toContain('-webkit-fill-available')
|
| 32 |
+
})
|
| 33 |
+
})
|
client/src/__tests__/html.test.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFileSync } from 'node:fs'
|
| 2 |
+
import { resolve } from 'node:path'
|
| 3 |
+
import { describe, it, expect } from 'vitest'
|
| 4 |
+
|
| 5 |
+
const html = readFileSync(resolve(__dirname, '../../index.html'), 'utf-8')
|
| 6 |
+
|
| 7 |
+
function findMeta(name: string): string | null {
|
| 8 |
+
const match = html.match(new RegExp(`<meta\\s+name="${name}"\\s+content="([^"]*)"`, 'i'))
|
| 9 |
+
return match?.[1] ?? null
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
describe('viewport meta', () => {
|
| 13 |
+
const content = findMeta('viewport')
|
| 14 |
+
|
| 15 |
+
it('has viewport meta', () => {
|
| 16 |
+
expect(content).not.toBeNull()
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
it('has width=device-width', () => {
|
| 20 |
+
expect(content).toContain('width=device-width')
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
it('has viewport-fit=cover', () => {
|
| 24 |
+
expect(content).toContain('viewport-fit=cover')
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
it('has user-scalable=no', () => {
|
| 28 |
+
expect(content).toContain('user-scalable=no')
|
| 29 |
+
})
|
| 30 |
+
|
| 31 |
+
it('has maximum-scale=1.0', () => {
|
| 32 |
+
expect(content).toContain('maximum-scale=1.0')
|
| 33 |
+
})
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
describe('theme-color meta', () => {
|
| 37 |
+
it('has theme-color meta tag', () => {
|
| 38 |
+
expect(findMeta('theme-color')).not.toBeNull()
|
| 39 |
+
})
|
| 40 |
+
})
|
client/src/__tests__/setup.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
import '@testing-library/jest-dom/vitest'
|