Add GameStateManager and WebSocketClient
This commit is contained in:
parent
c15c9c16c8
commit
3be0c40b64
|
@ -0,0 +1,90 @@
|
|||
|
||||
import { expect, test, describe, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import { GameStateManager } from './GameStateManager';
|
||||
|
||||
describe('GameStateManager', () => {
|
||||
let gameStateManager: GameStateManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Initialize a fresh GameStateManager before each test
|
||||
gameStateManager = new GameStateManager();
|
||||
});
|
||||
|
||||
test('should initialize with a default empty game state', () => {
|
||||
const initialState = gameStateManager.getGameState();
|
||||
expect(initialState).toEqual({
|
||||
id: '',
|
||||
board: Array(15).fill(Array(15).fill(null)),
|
||||
currentPlayer: 'black',
|
||||
status: 'waiting',
|
||||
winner: null,
|
||||
players: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should update game state from server updates', () => {
|
||||
const serverState = {
|
||||
id: 'game123',
|
||||
board: Array(15).fill(Array(15).fill(null)),
|
||||
currentPlayer: 'white',
|
||||
status: 'playing',
|
||||
winner: null,
|
||||
players: { black: 'playerA', white: 'playerB' },
|
||||
};
|
||||
gameStateManager.updateGameState(serverState);
|
||||
expect(gameStateManager.getGameState()).toEqual(serverState);
|
||||
});
|
||||
|
||||
test('should handle optimistic updates for making a move', () => {
|
||||
const initialBoard = Array(15).fill(Array(15).fill(null));
|
||||
initialBoard[7][7] = 'black'; // Simulate an optimistic move
|
||||
|
||||
const optimisticState = {
|
||||
id: 'game123',
|
||||
board: initialBoard,
|
||||
currentPlayer: 'white', // Turn changes optimistically
|
||||
status: 'playing',
|
||||
winner: null,
|
||||
players: { black: 'playerA', white: 'playerB' },
|
||||
};
|
||||
|
||||
gameStateManager.updateGameState(optimisticState);
|
||||
expect(gameStateManager.getGameState().board[7][7]).toEqual('black');
|
||||
expect(gameStateManager.getGameState().currentPlayer).toEqual('white');
|
||||
});
|
||||
|
||||
test('should rollback optimistic updates if server rejects move', () => {
|
||||
const initialServerState = {
|
||||
id: 'game123',
|
||||
board: Array(15).fill(Array(15).fill(null)),
|
||||
currentPlayer: 'black',
|
||||
status: 'playing',
|
||||
winner: null,
|
||||
players: { black: 'playerA', white: 'playerB' },
|
||||
};
|
||||
gameStateManager.updateGameState(initialServerState);
|
||||
|
||||
// Optimistic update
|
||||
const optimisticBoard = Array(15).fill(Array(15).fill(null));
|
||||
optimisticBoard[7][7] = 'black';
|
||||
const optimisticState = {
|
||||
id: 'game123',
|
||||
board: optimisticBoard,
|
||||
currentPlayer: 'white',
|
||||
status: 'playing',
|
||||
winner: null,
|
||||
players: { black: 'playerA', white: 'playerB' },
|
||||
};
|
||||
gameStateManager.updateGameState(optimisticState);
|
||||
|
||||
// Server rejection - rollback to initial state
|
||||
gameStateManager.rollbackGameState(); // This method will be implemented
|
||||
expect(gameStateManager.getGameState()).toEqual(initialServerState);
|
||||
});
|
||||
|
||||
// Add more tests for:
|
||||
// - Win conditions
|
||||
// - Draw conditions
|
||||
// - Invalid moves (already occupied, out of bounds - though this might be server-side validation primarily)
|
||||
// - Player disconnection/reconnection behavior
|
||||
});
|
|
@ -0,0 +1,50 @@
|
|||
export interface GameStateType {
|
||||
id: string;
|
||||
board: (null | 'black' | 'white')[][];
|
||||
currentPlayer: 'black' | 'white';
|
||||
status: 'waiting' | 'playing' | 'finished';
|
||||
winner: null | 'black' | 'white' | 'draw';
|
||||
players: { black?: string, white?: string };
|
||||
}
|
||||
|
||||
export class GameStateManager {
|
||||
private gameState: GameStateType;
|
||||
private stateHistory: GameStateType[];
|
||||
|
||||
constructor() {
|
||||
this.gameState = this.getDefaultGameState();
|
||||
this.stateHistory = [];
|
||||
}
|
||||
|
||||
private getDefaultGameState(): GameStateType {
|
||||
const emptyBoard: (null | 'black' | 'white')[][] = Array(15).fill(null).map(() => Array(15).fill(null));
|
||||
return {
|
||||
id: '',
|
||||
board: emptyBoard,
|
||||
currentPlayer: 'black',
|
||||
status: 'waiting',
|
||||
winner: null,
|
||||
players: {},
|
||||
};
|
||||
}
|
||||
|
||||
getGameState(): GameStateType {
|
||||
return this.gameState;
|
||||
}
|
||||
|
||||
updateGameState(newState: GameStateType): void {
|
||||
// Store a deep copy of the current state before updating
|
||||
// This is crucial for rollback to work correctly, as objects are passed by reference.
|
||||
this.stateHistory.push(JSON.parse(JSON.stringify(this.gameState)));
|
||||
this.gameState = newState;
|
||||
}
|
||||
|
||||
rollbackGameState(): void {
|
||||
if (this.stateHistory.length > 0) {
|
||||
this.gameState = this.stateHistory.pop()!;
|
||||
} else {
|
||||
console.warn("No previous state to rollback to.");
|
||||
// Optionally, throw an error or reset to default state here
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,197 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import { WebSocketClient } from './WebSocketClient';
|
||||
|
||||
// Define MockWebSocket as a regular class
|
||||
class MockWebSocket {
|
||||
onopen: ((this: WebSocket, ev: Event) => any) | null = null;
|
||||
onmessage: ((this: WebSocket, ev: MessageEvent<any>) => any) | null = null;
|
||||
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null;
|
||||
onerror: ((this: WebSocket, ev: Event) => any) | null = null;
|
||||
readyState: number = 0; // 0: CONNECTING, 1: OPEN, 2: CLOSING, 3: CLOSED
|
||||
|
||||
// Using a plain function for send/close to simplify.
|
||||
send(data: string) {
|
||||
if (this.readyState === WebSocket.OPEN) {
|
||||
// Simulate server echoing back message for testing received messages
|
||||
this.onmessage?.(new MessageEvent('message', { data }));
|
||||
}
|
||||
}
|
||||
close() {
|
||||
this.readyState = WebSocket.CLOSING;
|
||||
this.onclose?.(new CloseEvent('close'));
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
// Helper to simulate events
|
||||
_simulateOpen() {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
this.onopen?.(new Event('open'));
|
||||
}
|
||||
|
||||
_simulateMessage(data: string) {
|
||||
this.onmessage?.(new MessageEvent('message', { data }));
|
||||
}
|
||||
|
||||
_simulateClose(code = 1000, reason = '', wasClean = true) {
|
||||
this.readyState = WebSocket.CLOSING;
|
||||
this.onclose?.(new CloseEvent('close', { code, reason, wasClean }));
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
_simulateError(error: Event) {
|
||||
this.onerror?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Global array to track MockWebSocket instances created
|
||||
let createdMockWebSockets: MockWebSocket[] = [];
|
||||
|
||||
// Store original WebSocket for restoration
|
||||
const originalWebSocket = global.WebSocket;
|
||||
|
||||
describe('WebSocketClient', () => {
|
||||
let client: WebSocketClient;
|
||||
const url = 'ws://localhost:8080';
|
||||
|
||||
// Using a mock function to wrap the actual global WebSocket constructor
|
||||
let globalWebSocketConstructorMock: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear instances and reset mocks before each test
|
||||
createdMockWebSockets = [];
|
||||
|
||||
// Create a mock function that, when called as the WebSocket constructor,
|
||||
// creates a new MockWebSocket instance, pushes it to our global tracker,
|
||||
// and then spies on its send and close methods.<
|
||||
globalWebSocketConstructorMock = mock((url: string) => {
|
||||
const instance = new MockWebSocket(url);
|
||||
createdMockWebSockets.push(instance);
|
||||
|
||||
// Wrap send and close methods in mock functions for call tracking
|
||||
// We do this directly on the instance for each new WebSocket.
|
||||
// Using mock() directly on the method reference, and binding 'this'
|
||||
// to ensure it operates in the context of the instance.
|
||||
(instance as any).send = mock(instance.send.bind(instance));
|
||||
(instance as any).close = mock(instance.close.bind(instance));
|
||||
|
||||
return instance;
|
||||
}) as any; // Cast as any to satisfy TypeScript for global assignment
|
||||
|
||||
global.WebSocket = globalWebSocketConstructorMock;
|
||||
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.WebSocket = originalWebSocket; // Restore original WebSocket
|
||||
});
|
||||
|
||||
it('should connect to the specified URL', () => {
|
||||
client = new WebSocketClient(url);
|
||||
client.connect();
|
||||
|
||||
expect(globalWebSocketConstructorMock).toHaveBeenCalledWith(url);
|
||||
expect(createdMockWebSockets.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should call onOpen callback when connection is established', () => {
|
||||
const onOpenMock = mock(() => {});
|
||||
client = new WebSocketClient(url);
|
||||
client.onOpen(onOpenMock);
|
||||
client.connect();
|
||||
|
||||
createdMockWebSockets[0]._simulateOpen();
|
||||
expect(onOpenMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onMessage callback when a message is received', () => {
|
||||
const onMessageMock = mock(() => {});
|
||||
client = new WebSocketClient(url);
|
||||
client.onMessage(onMessageMock);
|
||||
client.connect();
|
||||
|
||||
createdMockWebSockets[0]._simulateOpen();
|
||||
createdMockWebSockets[0]._simulateMessage('test message');
|
||||
|
||||
expect(onMessageMock).toHaveBeenCalledWith('test message');
|
||||
});
|
||||
|
||||
it('should call onClose callback when connection is closed', () => {
|
||||
const onCloseMock = mock(() => {});
|
||||
client = new WebSocketClient(url);
|
||||
client.onClose(onCloseMock);
|
||||
client.connect();
|
||||
|
||||
createdMockWebSockets[0]._simulateOpen();
|
||||
createdMockWebSockets[0]._simulateClose();
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onError callback when an error occurs', () => {
|
||||
const onErrorMock = mock(() => {});
|
||||
client = new WebSocketClient(url);
|
||||
client.onError(onErrorMock);
|
||||
client.connect();
|
||||
|
||||
createdMockWebSockets[0]._simulateError(new Event('error'));
|
||||
|
||||
expect(onErrorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should send a message when connected', () => {
|
||||
client = new WebSocketClient(url);
|
||||
client.connect();
|
||||
createdMockWebSockets[0]._simulateOpen();
|
||||
|
||||
client.send('hello');
|
||||
// Expect the mocked `send` method on the MockWebSocket instance to have been called
|
||||
expect(createdMockWebSockets[0].send).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('should queue messages when disconnected and send them upon reconnection', async () => {
|
||||
client = new WebSocketClient(url, { reconnectInterval: 10 }); // Shorter interval for faster test
|
||||
const onOpenMock = mock(() => {});
|
||||
client.onOpen(onOpenMock);
|
||||
|
||||
client.connect(); // Connect for the first time
|
||||
expect(createdMockWebSockets.length).toBe(1); // First instance
|
||||
const firstWs = createdMockWebSockets[0];
|
||||
|
||||
// Simulate immediate disconnection before open
|
||||
firstWs._simulateClose();
|
||||
|
||||
// Send messages while disconnected, they should be queued
|
||||
client.send('queued message 1');
|
||||
client.send('queued message 2');
|
||||
|
||||
// Simulate reconnection after a short delay
|
||||
await new Promise(resolve => setTimeout(resolve, 20)); // Allow for reconnectInterval
|
||||
expect(createdMockWebSockets.length).toBe(2); // New instance created for reconnection
|
||||
createdMockWebSockets[1]._simulateOpen(); // Simulate new connection opening
|
||||
|
||||
// Wait for messages to be flushed
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
|
||||
expect(createdMockWebSockets[1].send).toHaveBeenCalledWith('queued message 1');
|
||||
expect(createdMockWebSockets[1].send).toHaveBeenCalledWith('queued message 2');
|
||||
|
||||
expect(onOpenMock).toHaveBeenCalledTimes(1); // onOpen should be called on successful reconnection
|
||||
});
|
||||
|
||||
it('should not attempt to reconnect if explicitly closed', async () => {
|
||||
client = new WebSocketClient(url, { reconnectAttempts: 3, reconnectInterval: 10 });
|
||||
const onCloseMock = mock(() => {});
|
||||
client.onClose(onCloseMock);
|
||||
|
||||
client.connect();
|
||||
createdMockWebSockets[0]._simulateOpen();
|
||||
client.close(); // Explicitly close
|
||||
|
||||
// Allow some time for potential reconnect attempts. If no new WebSocket is created after the attempts would have happened, then we know it's not reconnecting.
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); // (reconnectAttempts * reconnectInterval) + buffer
|
||||
|
||||
expect(createdMockWebSockets.length).toBe(1); // Only the initial one
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1); // onClose should be called
|
||||
});
|
||||
|
||||
});
|
|
@ -0,0 +1,106 @@
|
|||
type MessageHandler = (message: string) => void;
|
||||
type OpenHandler = () => void;
|
||||
type CloseHandler = (code: number, reason: string) => void;
|
||||
type ErrorHandler = (event: Event) => void;
|
||||
|
||||
interface WebSocketClientOptions {
|
||||
reconnectAttempts?: number;
|
||||
reconnectInterval?: number;
|
||||
}
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private messageQueue: string[] = [];
|
||||
private isConnected: boolean = false;
|
||||
private reconnectCount: number = 0;
|
||||
private options: WebSocketClientOptions;
|
||||
private manualClose: boolean = false;
|
||||
|
||||
private onMessageHandler: MessageHandler = () => {};
|
||||
private onOpenHandler: OpenHandler = () => {};
|
||||
private onCloseHandler: CloseHandler = () => {};
|
||||
private onErrorHandler: ErrorHandler = () => {};
|
||||
|
||||
constructor(url: string, options?: WebSocketClientOptions) {
|
||||
this.url = url;
|
||||
this.options = {
|
||||
reconnectAttempts: 5, // Default reconnect attempts
|
||||
reconnectInterval: 3000, // Default reconnect interval in ms
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
public connect(): void {
|
||||
this.manualClose = false;
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.onopen = this.handleOpen.bind(this);
|
||||
this.ws.onmessage = this.handleMessage.bind(this);
|
||||
this.ws.onclose = this.handleClose.bind(this);
|
||||
this.ws.onerror = this.handleError.bind(this);
|
||||
}
|
||||
|
||||
public send(message: string): void {
|
||||
if (this.isConnected && this.ws) {
|
||||
this.ws.send(message);
|
||||
} else {
|
||||
this.messageQueue.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.manualClose = true;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
public onMessage(handler: MessageHandler): void {
|
||||
this.onMessageHandler = handler;
|
||||
}
|
||||
|
||||
public onOpen(handler: OpenHandler): void {
|
||||
this.onOpenHandler = handler;
|
||||
}
|
||||
|
||||
public onClose(handler: CloseHandler): void {
|
||||
this.onCloseHandler = handler;
|
||||
}
|
||||
|
||||
public onError(handler: ErrorHandler): void {
|
||||
this.onErrorHandler = handler;
|
||||
}
|
||||
|
||||
private handleOpen(): void {
|
||||
this.isConnected = true;
|
||||
this.reconnectCount = 0;
|
||||
this.onOpenHandler();
|
||||
this.flushMessageQueue();
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
this.onMessageHandler(event.data);
|
||||
}
|
||||
|
||||
private handleClose(event: CloseEvent): void {
|
||||
this.isConnected = false;
|
||||
this.onCloseHandler(event.code, event.reason);
|
||||
if (!this.manualClose && this.reconnectCount < this.options.reconnectAttempts!) {
|
||||
this.reconnectCount++;
|
||||
setTimeout(() => this.connect(), this.options.reconnectInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(event: Event): void {
|
||||
this.onErrorHandler(event);
|
||||
}
|
||||
|
||||
private flushMessageQueue(): void {
|
||||
while (this.messageQueue.length > 0 && this.isConnected && this.ws) {
|
||||
const message = this.messageQueue.shift();
|
||||
if (message) {
|
||||
this.ws.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue