Pedro de Carvalho commited on
Commit
4fa92d9
·
1 Parent(s): 0c380b8

Add ui tests

Browse files
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'