Spaces:
Runtime error
Runtime error
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' | |
| import { GameEngine } from '../GameEngine.ts' | |
| import { BoardStore } from '../BoardStore.ts' | |
| import { GameManager } from 'shared/GameManager.ts' | |
| vi.mock('pixi.js', () => ({ | |
| Application: vi.fn(() => ({ | |
| init: vi.fn(), | |
| renderer: { background: { color: 0 }, resize: vi.fn() }, | |
| stage: { addChild: vi.fn() }, | |
| canvas: {} as HTMLCanvasElement, | |
| })), | |
| Container: vi.fn(() => ({ eventMode: 'none' })), | |
| Graphics: vi.fn(() => ({ | |
| rect: vi.fn(), | |
| fill: vi.fn(), | |
| stroke: vi.fn(), | |
| clear: vi.fn(), | |
| })), | |
| Ticker: { shared: { add: vi.fn(), remove: vi.fn(), deltaMS: 16 } }, | |
| })) | |
| vi.mock('../CameraController.ts', () => ({ | |
| CameraController: vi.fn(() => ({ | |
| setInitialZoom: vi.fn(), | |
| enable: vi.fn(), | |
| onChange: null, | |
| onCellClick: null, | |
| onCellHover: null, | |
| ensureCellsInView: vi.fn(), | |
| resize: vi.fn(), | |
| })), | |
| })) | |
| vi.mock('../GridRenderer.ts', () => ({ | |
| GridRenderer: vi.fn(() => ({ | |
| container: { addChild: vi.fn() }, | |
| draw: vi.fn(), | |
| })), | |
| })) | |
| vi.mock('../SymbolRenderer.ts', () => ({ | |
| SymbolRenderer: vi.fn(() => ({ | |
| container: { addChild: vi.fn() }, | |
| update: vi.fn(), | |
| clear: vi.fn(), | |
| getSymbol: vi.fn(), | |
| })), | |
| })) | |
| vi.mock('../effects/AudioManager.ts', () => ({ | |
| AudioManager: vi.fn(() => ({ | |
| play: vi.fn(), | |
| playHover: vi.fn(), | |
| startMusic: vi.fn(), | |
| stopMusic: vi.fn(), | |
| destroy: vi.fn(), | |
| toggleMute: vi.fn(() => false), | |
| playVictoryMusic: vi.fn(), | |
| playDefeatMusic: vi.fn(), | |
| muted: false, | |
| })), | |
| audioManager: { | |
| play: vi.fn(), | |
| playHover: vi.fn(), | |
| startMusic: vi.fn(), | |
| stopMusic: vi.fn(), | |
| destroy: vi.fn(), | |
| toggleMute: vi.fn(() => false), | |
| playVictoryMusic: vi.fn(), | |
| playDefeatMusic: vi.fn(), | |
| muted: false, | |
| }, | |
| })) | |
| vi.mock('../effects/ParticleSystem.ts', () => ({ | |
| ParticleSystem: vi.fn(() => ({ | |
| update: vi.fn(), | |
| clear: vi.fn(), | |
| })), | |
| })) | |
| vi.mock('../effects/PlacementEffect.ts', () => ({ | |
| PlacementEffect: vi.fn(() => ({ play: vi.fn() })), | |
| })) | |
| vi.mock('../effects/WinEffect.ts', () => ({ | |
| WinEffect: vi.fn(() => ({ play: vi.fn() })), | |
| })) | |
| beforeEach(() => { | |
| vi.useFakeTimers() | |
| }) | |
| afterEach(() => { | |
| vi.useRealTimers() | |
| }) | |
| function createEngine(): GameEngine { | |
| const engine = new GameEngine() | |
| engine.board = new BoardStore() | |
| engine.status = 'active' | |
| engine.localMode = true | |
| engine.currentPlayer = 0 | |
| engine.playerIndex = 0 | |
| engine.playerSymbols = ['X', 'O'] | |
| engine.players = ['P1', 'P2'] | |
| engine.manager = new GameManager('P1', 'P2', ['X', 'O']) | |
| engine.onStateChanged = vi.fn() | |
| engine.onError = vi.fn() | |
| engine['redraw'] = vi.fn() | |
| engine['playPlacementEffect'] = vi.fn() | |
| engine['winEffect'] = { play: vi.fn() } as any | |
| engine.audio = { | |
| play: vi.fn(), | |
| stopMusic: vi.fn(), | |
| startMusic: vi.fn(), | |
| playVictoryMusic: vi.fn(), | |
| playDefeatMusic: vi.fn(), | |
| } as any | |
| return engine | |
| } | |
| describe('GameEngine local mode', () => { | |
| describe('turn switching', () => { | |
| it('starts with player 0', () => { | |
| const engine = createEngine() | |
| expect(engine.currentPlayer).toBe(0) | |
| }) | |
| it('switches to player 1 after a non-winning move', () => { | |
| const engine = createEngine() | |
| engine['handleCellClick'](0, 0) | |
| expect(engine.currentPlayer).toBe(1) | |
| expect(engine.onStateChanged).toHaveBeenCalled() | |
| }) | |
| it('switches back to player 0 after second move', () => { | |
| const engine = createEngine() | |
| engine['handleCellClick'](0, 0) | |
| engine['handleCellClick'](1, 0) | |
| expect(engine.currentPlayer).toBe(0) | |
| }) | |
| it('sets currentPlayer to -1 during win check', () => { | |
| const engine = createEngine() | |
| engine.manager.addMove(0, 0, 0, 'X') | |
| engine.manager.addMove(1, 0, 0, 'X') | |
| engine.manager.addMove(2, 0, 0, 'X') | |
| engine.manager.addMove(3, 0, 0, 'X') | |
| engine['handleCellClick'](4, 0) | |
| vi.advanceTimersByTime(100) | |
| expect(engine.winnerPlayerId).toBe('0') | |
| }) | |
| it('does not switch turn when game is not active', () => { | |
| const engine = createEngine() | |
| engine.status = 'waiting' | |
| engine['handleCellClick'](0, 0) | |
| expect(engine.currentPlayer).toBe(0) | |
| }) | |
| }) | |
| describe('win detection', () => { | |
| it('detects a horizontal win and calls applyWin', () => { | |
| const engine = createEngine() | |
| engine.manager.addMove(0, 0, 0, 'X') | |
| engine.manager.addMove(1, 0, 0, 'X') | |
| engine.manager.addMove(2, 0, 0, 'X') | |
| engine.manager.addMove(3, 0, 0, 'X') | |
| engine['handleCellClick'](4, 0) | |
| vi.advanceTimersByTime(100) | |
| expect(engine.status).toBe('win') | |
| expect(engine.winnerPlayerId).toBe('0') | |
| }) | |
| it('detects a vertical win', () => { | |
| const engine = createEngine() | |
| engine.manager.addMove(0, 0, 1, 'O') | |
| engine.manager.addMove(0, 1, 1, 'O') | |
| engine.manager.addMove(0, 2, 1, 'O') | |
| engine.manager.addMove(0, 3, 1, 'O') | |
| engine.manager.setCurrentPlayer(1) | |
| engine['handleCellClick'](0, 4) | |
| vi.advanceTimersByTime(100) | |
| expect(engine.status).toBe('win') | |
| expect(engine.winnerPlayerId).toBe('1') | |
| }) | |
| it('detects a diagonal win (top-left to bottom-right)', () => { | |
| const engine = createEngine() | |
| engine.manager.addMove(0, 0, 0, 'X') | |
| engine.manager.addMove(1, 1, 0, 'X') | |
| engine.manager.addMove(2, 2, 0, 'X') | |
| engine.manager.addMove(3, 3, 0, 'X') | |
| engine['handleCellClick'](4, 4) | |
| vi.advanceTimersByTime(100) | |
| expect(engine.status).toBe('win') | |
| expect(engine.winnerPlayerId).toBe('0') | |
| }) | |
| it('detects a diagonal win (top-right to bottom-left)', () => { | |
| const engine = createEngine() | |
| engine.manager.addMove(4, 0, 0, 'X') | |
| engine.manager.addMove(3, 1, 0, 'X') | |
| engine.manager.addMove(2, 2, 0, 'X') | |
| engine.manager.addMove(1, 3, 0, 'X') | |
| engine['handleCellClick'](0, 4) | |
| vi.advanceTimersByTime(100) | |
| expect(engine.status).toBe('win') | |
| expect(engine.winnerPlayerId).toBe('0') | |
| }) | |
| it('does not trigger win for 4 in a row', () => { | |
| const engine = createEngine() | |
| engine.manager.addMove(0, 0, 0, 'X') | |
| engine.manager.addMove(1, 0, 0, 'X') | |
| engine.manager.addMove(2, 0, 0, 'X') | |
| engine['handleCellClick'](3, 0) | |
| expect(engine.status).toBe('active') | |
| expect(engine.currentPlayer).toBe(1) | |
| }) | |
| it('does not trigger win when blocked on both ends', () => { | |
| const engine = createEngine() | |
| engine.manager.addMove(0, 0, 1, 'O') | |
| engine.manager.addMove(1, 0, 0, 'X') | |
| engine.manager.addMove(2, 0, 0, 'X') | |
| engine.manager.addMove(3, 0, 0, 'X') | |
| engine.manager.addMove(4, 0, 0, 'X') | |
| engine.manager.addMove(5, 0, 0, 'X') | |
| engine.manager.setCurrentPlayer(1) | |
| engine['handleCellClick'](6, 0) | |
| vi.advanceTimersByTime(100) | |
| expect(engine.status).not.toBe('win') | |
| }) | |
| }) | |
| describe('draw detection', () => { | |
| it('detects a draw when the board is full', () => { | |
| const engine = createEngine() | |
| for (let x = 0; x < 15; x++) { | |
| for (let y = 0; y < 15; y++) { | |
| if (x === 14 && y === 14) break | |
| engine.manager.addMove(x, y, (x + y) % 2, (x + y) % 2 === 0 ? 'X' : 'O') | |
| } | |
| } | |
| engine.manager.setCurrentPlayer(1) | |
| engine['handleCellClick'](14, 14) | |
| vi.advanceTimersByTime(100) | |
| expect(engine.status).toBe('draw') | |
| }) | |
| it('does not trigger draw when board is not full', () => { | |
| const engine = createEngine() | |
| engine['handleCellClick'](0, 0) | |
| expect(engine.status).toBe('active') | |
| }) | |
| }) | |
| describe('cell placement', () => { | |
| it('places a cell on the board', () => { | |
| const engine = createEngine() | |
| engine['handleCellClick'](5, 5) | |
| const cell = engine.board.getCell(5, 5) | |
| expect(cell).toBeDefined() | |
| expect(cell!.playerId).toBe('0') | |
| }) | |
| it('does not place a cell on an occupied cell', () => { | |
| const engine = createEngine() | |
| engine.manager.addMove(5, 5, 0, 'X') | |
| engine['handleCellClick'](5, 5) | |
| expect(engine.board.getCell(5, 5)).toBeUndefined() | |
| expect(engine.currentPlayer).toBe(0) | |
| }) | |
| it('does not place a cell outside the board', () => { | |
| const engine = createEngine() | |
| engine['handleCellClick'](-1, 0) | |
| engine['handleCellClick'](0, -1) | |
| engine['handleCellClick'](15, 0) | |
| engine['handleCellClick'](0, 15) | |
| expect(engine.board.cells.size).toBe(0) | |
| expect(engine.manager?.getBoard().size).toBe(0) | |
| expect(engine.currentPlayer).toBe(0) | |
| }) | |
| it('does not place a cell on malformed coordinates', () => { | |
| const engine = createEngine() | |
| engine['handleCellClick'](1.5, 1) | |
| engine['handleCellClick'](Number.NaN, 1) | |
| expect(engine.board.cells.size).toBe(0) | |
| expect(engine.manager?.getBoard().size).toBe(0) | |
| expect(engine.currentPlayer).toBe(0) | |
| }) | |
| it('ignores out-of-bounds cells from remote updates', () => { | |
| const engine = createEngine() | |
| const cell = { playerId: '0', symbol: 'X', timestamp: 0 } | |
| engine.symbols = { clear: vi.fn() } as any | |
| engine.camera = { ensureCellsInView: vi.fn() } as any | |
| engine.addCell(15, 0, cell) | |
| engine.setCells({ | |
| '14:14': cell, | |
| '15:0': cell, | |
| '0:15': cell, | |
| '-1:0': cell, | |
| }) | |
| expect(engine.board.getCell(14, 14)).toEqual(cell) | |
| expect(engine.board.getCell(15, 0)).toBeUndefined() | |
| expect(engine.board.getCell(0, 15)).toBeUndefined() | |
| expect(engine.board.getCell(-1, 0)).toBeUndefined() | |
| }) | |
| it('uses correct symbol for each player', () => { | |
| const engine = createEngine() | |
| engine['handleCellClick'](0, 0) | |
| const cell0 = engine.board.getCell(0, 0) | |
| expect(cell0!.symbol).toBe('X') | |
| engine['handleCellClick'](1, 0) | |
| const cell1 = engine.board.getCell(1, 0) | |
| expect(cell1!.symbol).toBe('O') | |
| }) | |
| }) | |
| describe('swap2 rendering', () => { | |
| it('clears existing symbols before redrawing swapped owner icons', () => { | |
| const engine = createEngine() | |
| engine.manager = new GameManager('P1', 'P2', ['X', 'O'], { swap2: true, noOverlines: true, boardSize: 15 }) | |
| engine.symbols = { clear: vi.fn() } as any | |
| engine.manager.placeMove(7, 7) | |
| engine.manager.placeMove(8, 7) | |
| engine.manager.placeMove(9, 7) | |
| engine.manager.chooseSwap(true) | |
| engine.applySwapFromManager() | |
| expect(engine.symbols.clear).toHaveBeenCalled() | |
| expect(engine.board.getCell(7, 7)).toMatchObject({ playerId: '1', symbol: 'X' }) | |
| expect(engine.board.getCell(9, 7)).toMatchObject({ playerId: '0', symbol: 'O' }) | |
| }) | |
| }) | |
| }) | |