398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
|
import { WebSocketHandler } from './WebSocketHandler';
|
|
import { GameManager } from './GameManager';
|
|
import { GameInstance } from './GameInstance';
|
|
|
|
// Mock ElysiaWS type for testing purposes - fully compatible with standard WebSocket
|
|
type MockElysiaWS = {
|
|
send: ReturnType<typeof mock>;
|
|
close: ReturnType<typeof mock>;
|
|
on: ReturnType<typeof mock>;
|
|
_messageCallback: ((message: string) => void) | null;
|
|
_closeCallback: (() => void) | null;
|
|
_errorCallback: ((error: Error) => void) | null;
|
|
data: {
|
|
gameId?: string;
|
|
playerId?: string;
|
|
query: Record<string, string>;
|
|
};
|
|
// Standard WebSocket properties
|
|
binaryType: 'blob' | 'arraybuffer';
|
|
bufferedAmount: number;
|
|
extensions: string;
|
|
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null;
|
|
onerror: ((this: WebSocket, ev: Event) => any) | null;
|
|
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null;
|
|
onopen: ((this: WebSocket, ev: Event) => any) | null;
|
|
protocol: string;
|
|
readyState: number;
|
|
url: string;
|
|
CLOSED: number;
|
|
CONNECTING: number;
|
|
OPEN: number;
|
|
CLOSING: number;
|
|
dispatchEvent: ReturnType<typeof mock>;
|
|
addEventListener: ReturnType<typeof mock>;
|
|
removeEventListener: ReturnType<typeof mock>;
|
|
ping: ReturnType<typeof mock>; // Bun.js specific
|
|
pong: ReturnType<typeof mock>; // Bun.js specific
|
|
subscribe: ReturnType<typeof mock>; // Bun.js specific
|
|
unsubscribe: ReturnType<typeof mock>; // Bun.js specific
|
|
};
|
|
|
|
describe('WebSocketHandler', () => {
|
|
let gameManager: GameManager;
|
|
let webSocketHandler: WebSocketHandler;
|
|
let mockWs: MockElysiaWS;
|
|
|
|
beforeEach(() => {
|
|
mockWs = {
|
|
// Mock standard WebSocket methods
|
|
send: mock(() => {}),
|
|
close: mock(() => {}),
|
|
|
|
// Mock custom 'on' method for attaching callbacks
|
|
on: mock((event: string, callback: (...args: any[]) => void) => {
|
|
if (event === 'message') (mockWs as any)._messageCallback = callback;
|
|
if (event === 'close') (mockWs as any)._closeCallback = callback;
|
|
if (event === 'error') (mockWs as any)._errorCallback = callback;
|
|
}),
|
|
|
|
_messageCallback: null,
|
|
_closeCallback: null,
|
|
_errorCallback: null,
|
|
|
|
data: { query: {} },
|
|
|
|
// Initialize all standard WebSocket properties
|
|
binaryType: 'blob',
|
|
bufferedAmount: 0,
|
|
extensions: '',
|
|
onclose: null,
|
|
onerror: null,
|
|
onmessage: null,
|
|
onopen: null,
|
|
protocol: '',
|
|
readyState: 1,
|
|
url: '',
|
|
CLOSED: 3,
|
|
CONNECTING: 0,
|
|
OPEN: 1,
|
|
CLOSING: 2,
|
|
dispatchEvent: mock(() => {}),
|
|
addEventListener: mock(() => {}),
|
|
removeEventListener: mock(() => {}),
|
|
ping: mock(() => {}),
|
|
pong: mock(() => {}),
|
|
subscribe: mock(() => {}),
|
|
unsubscribe: mock(() => {}),
|
|
};
|
|
gameManager = new GameManager();
|
|
webSocketHandler = new WebSocketHandler(gameManager);
|
|
});
|
|
|
|
const triggerMessage = (ws: MockElysiaWS, message: string) => {
|
|
if (ws._messageCallback) {
|
|
ws._messageCallback(message);
|
|
}
|
|
};
|
|
|
|
const triggerClose = (ws: MockElysiaWS) => {
|
|
if (ws._closeCallback) {
|
|
ws._closeCallback();
|
|
}
|
|
};
|
|
|
|
const triggerError = (ws: MockElysiaWS, error: Error) => {
|
|
if (ws._errorCallback) {
|
|
ws._errorCallback(error);
|
|
}
|
|
};
|
|
|
|
const createNewMockWs = (): MockElysiaWS => ({
|
|
send: mock(() => {}),
|
|
close: mock(() => {}),
|
|
on: mock((event: string, callback: (...args: any[]) => void) => {
|
|
if (event === 'message')
|
|
(createNewMockWs() as any)._messageCallback = callback;
|
|
if (event === 'close')
|
|
(createNewMockWs() as any)._closeCallback = callback;
|
|
if (event === 'error')
|
|
(createNewMockWs() as any)._errorCallback = callback;
|
|
}),
|
|
_messageCallback: null,
|
|
_closeCallback: null,
|
|
_errorCallback: null,
|
|
data: { query: {} },
|
|
binaryType: 'blob',
|
|
bufferedAmount: 0,
|
|
extensions: '',
|
|
onclose: null,
|
|
onerror: null,
|
|
onmessage: null,
|
|
onopen: null,
|
|
protocol: '',
|
|
readyState: 1,
|
|
url: '',
|
|
CLOSED: 3,
|
|
CONNECTING: 0,
|
|
OPEN: 1,
|
|
CLOSING: 2,
|
|
dispatchEvent: mock(() => {}),
|
|
addEventListener: mock(() => {}),
|
|
removeEventListener: mock(() => {}),
|
|
ping: mock(() => {}),
|
|
pong: mock(() => {}),
|
|
subscribe: mock(() => {}),
|
|
unsubscribe: mock(() => {}),
|
|
});
|
|
|
|
it('should register a new connection', () => {
|
|
mockWs.data.gameId = 'test-game';
|
|
mockWs.data.playerId = 'player-alpha';
|
|
mockWs.data.query.gameId = 'test-game';
|
|
mockWs.data.query.playerId = 'player-alpha';
|
|
webSocketHandler.handleConnection(mockWs);
|
|
expect((webSocketHandler as any).connections.get('test-game')).toContain(
|
|
mockWs,
|
|
);
|
|
});
|
|
|
|
it('should process a join_game message for an already connected client', () => {
|
|
const gameId = gameManager.createGame().id;
|
|
mockWs.data.query.gameId = gameId;
|
|
mockWs.data.query.playerId = 'player1';
|
|
mockWs.data.gameId = gameId;
|
|
mockWs.data.playerId = 'player1';
|
|
webSocketHandler.handleConnection(mockWs);
|
|
const joinGameMessage = JSON.stringify({
|
|
type: 'join_game',
|
|
gameId: gameId,
|
|
playerId: 'player1',
|
|
});
|
|
triggerMessage(mockWs, joinGameMessage);
|
|
|
|
expect(mockWs.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="game-board"'),
|
|
);
|
|
expect(mockWs.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="player-info"'),
|
|
);
|
|
});
|
|
|
|
it('should handle a make_move message and broadcast HTML updates', () => {
|
|
const game = gameManager.createGame();
|
|
game.addPlayer('player1');
|
|
game.addPlayer('player2');
|
|
game.currentPlayer = 'black';
|
|
|
|
mockWs.data.gameId = game.id;
|
|
mockWs.data.playerId = 'player1';
|
|
mockWs.data.query.gameId = game.id;
|
|
mockWs.data.query.playerId = 'player1';
|
|
webSocketHandler.handleConnection(mockWs);
|
|
|
|
const makeMoveMessage = JSON.stringify({
|
|
type: 'make_move',
|
|
gameId: game.id,
|
|
playerId: 'player1',
|
|
row: 7,
|
|
col: 7,
|
|
});
|
|
triggerMessage(mockWs, makeMoveMessage);
|
|
|
|
expect(mockWs.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="game-board"'),
|
|
);
|
|
expect(mockWs.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="player-info"'),
|
|
);
|
|
expect(mockWs.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="messages"'),
|
|
);
|
|
|
|
expect(game.board[7][7]).toBe('black');
|
|
expect(game.currentPlayer).toBe('white');
|
|
});
|
|
|
|
it('should send an error for an invalid move', () => {
|
|
const game = gameManager.createGame();
|
|
game.addPlayer('player1');
|
|
game.addPlayer('player2');
|
|
game.currentPlayer = 'black';
|
|
|
|
mockWs.data.gameId = game.id;
|
|
mockWs.data.playerId = 'player1';
|
|
mockWs.data.query.gameId = game.id;
|
|
mockWs.data.query.playerId = 'player1';
|
|
webSocketHandler.handleConnection(mockWs);
|
|
|
|
const makeMoveMessage1 = JSON.stringify({
|
|
type: 'make_move',
|
|
gameId: game.id,
|
|
playerId: 'player1',
|
|
row: 7,
|
|
col: 7,
|
|
});
|
|
triggerMessage(mockWs, makeMoveMessage1);
|
|
mockWs.send.mockClear();
|
|
|
|
triggerMessage(mockWs, makeMoveMessage1);
|
|
|
|
expect(mockWs.send).toHaveBeenCalledWith(
|
|
JSON.stringify({ type: 'error', error: 'Cell already occupied' }),
|
|
);
|
|
});
|
|
|
|
it('should handle ping/pong messages', () => {
|
|
webSocketHandler.handleConnection(mockWs);
|
|
const pingMessage = JSON.stringify({ type: 'ping' });
|
|
triggerMessage(mockWs, pingMessage);
|
|
|
|
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }));
|
|
});
|
|
|
|
it('should handle player disconnection and notify others', () => {
|
|
const game = gameManager.createGame();
|
|
const player1Ws = createNewMockWs();
|
|
const player2Ws = createNewMockWs();
|
|
|
|
player1Ws.data.gameId = game.id;
|
|
player1Ws.data.playerId = 'player1';
|
|
player1Ws.data.query.gameId = game.id;
|
|
player1Ws.data.query.playerId = 'player1';
|
|
|
|
player2Ws.data.gameId = game.id;
|
|
player2Ws.data.playerId = 'player2';
|
|
player2Ws.data.query.gameId = game.id;
|
|
player2Ws.data.query.playerId = 'player2';
|
|
|
|
webSocketHandler.handleConnection(player1Ws);
|
|
webSocketHandler.handleConnection(player2Ws);
|
|
|
|
player1Ws.send.mockClear();
|
|
player2Ws.send.mockClear();
|
|
|
|
webSocketHandler.handleDisconnect(player2Ws);
|
|
|
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="player-info"'),
|
|
);
|
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="messages"'),
|
|
);
|
|
expect(
|
|
player1Ws.send.mock.calls
|
|
.flat()
|
|
.some((call) => (call as string).includes('player2 disconnected')),
|
|
).toBeTrue();
|
|
expect((webSocketHandler as any).connections.get(game.id)).not.toContain(
|
|
player2Ws,
|
|
);
|
|
});
|
|
|
|
it('should send error for unknown message type', () => {
|
|
webSocketHandler.handleConnection(mockWs);
|
|
const unknownMessage = JSON.stringify({ type: 'unknown_type' });
|
|
triggerMessage(mockWs, unknownMessage);
|
|
|
|
expect(mockWs.send).toHaveBeenCalledWith(
|
|
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
|
|
);
|
|
});
|
|
|
|
it('should send error for invalid JSON message', () => {
|
|
webSocketHandler.handleConnection(mockWs);
|
|
const invalidJsonMessage = 'not a json';
|
|
triggerMessage(mockWs, invalidJsonMessage);
|
|
|
|
expect(mockWs.send).toHaveBeenCalledWith(
|
|
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
|
|
);
|
|
});
|
|
|
|
it('should broadcast game state to a specific client when targetWs is provided', () => {
|
|
const game = gameManager.createGame();
|
|
game.addPlayer('player1');
|
|
game.addPlayer('player2');
|
|
game.currentPlayer = 'black';
|
|
|
|
const player1Ws = createNewMockWs();
|
|
player1Ws.data.gameId = game.id;
|
|
player1Ws.data.playerId = 'player1';
|
|
player1Ws.data.query.gameId = game.id;
|
|
player1Ws.data.query.playerId = 'player1';
|
|
webSocketHandler.handleConnection(player1Ws);
|
|
|
|
const player2Ws = createNewMockWs();
|
|
player2Ws.data.gameId = game.id;
|
|
player2Ws.data.playerId = 'player2';
|
|
player2Ws.data.query.gameId = game.id;
|
|
player2Ws.data.query.playerId = 'player2';
|
|
webSocketHandler.handleConnection(player2Ws);
|
|
|
|
player1Ws.send.mockClear();
|
|
player2Ws.send.mockClear();
|
|
|
|
webSocketHandler.broadcastGameUpdate(game.id, game, null, null, player1Ws);
|
|
|
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="game-board"'),
|
|
);
|
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="player-info"'),
|
|
);
|
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="messages"'),
|
|
);
|
|
|
|
expect(player2Ws.send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should broadcast game state to all clients if targetWs is not provided', () => {
|
|
const game = gameManager.createGame();
|
|
game.addPlayer('player1');
|
|
game.addPlayer('player2');
|
|
game.currentPlayer = 'black';
|
|
|
|
const player1Ws = createNewMockWs();
|
|
player1Ws.data.gameId = game.id;
|
|
player1Ws.data.playerId = 'player1';
|
|
player1Ws.data.query.gameId = game.id;
|
|
player1Ws.data.query.playerId = 'player1';
|
|
webSocketHandler.handleConnection(player1Ws);
|
|
|
|
const player2Ws = createNewMockWs();
|
|
player2Ws.data.gameId = game.id;
|
|
player2Ws.data.playerId = 'player2';
|
|
player2Ws.data.query.gameId = game.id;
|
|
player2Ws.data.query.playerId = 'player2';
|
|
webSocketHandler.handleConnection(player2Ws);
|
|
|
|
player1Ws.send.mockClear();
|
|
player2Ws.send.mockClear();
|
|
|
|
webSocketHandler.broadcastGameUpdate(game.id, game);
|
|
|
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="game-board"'),
|
|
);
|
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="player-info"'),
|
|
);
|
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="messages"'),
|
|
);
|
|
|
|
expect(player2Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="game-board"'),
|
|
);
|
|
expect(player2Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="player-info"'),
|
|
);
|
|
expect(player2Ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('<div id="messages"'),
|
|
);
|
|
});
|
|
});
|