import assert from 'node:assert/strict' import { io } from 'socket.io-client' const SOCKET_SERVER_PORT = process.env.SOCKET_SERVER_PORT || '31873' const SOCKET_SERVER_URL = `http://127.0.0.1:${SOCKET_SERVER_PORT}` /** * 테스트에 사용할 유효한 50장 덱 URL을 만든다. * * @returns {string} 덱 등록용 결과 페이지 URL */ function createValidDeckUrl() { const cards = Array.from({ length: 13 }, (_, cardIndex) => ({ imgSrc: `/ua01/TEST-${String(cardIndex + 1).padStart(3, '0')}.png`, Number: `TEST-${String(cardIndex + 1).padStart(3, '0')}`, count: cardIndex === 12 ? 2 : 4, color: 'Blue', trigger: null, })) const deckUrl = new URL('http://uniare.sori.studio/result.html') deckUrl.searchParams.set('series', 'ua01') deckUrl.searchParams.set('seriesName', 'Code Geass') deckUrl.searchParams.set('cards', JSON.stringify(cards)) return deckUrl.toString() } /** * 연결 완료 또는 오류를 Promise로 기다린다. * * @param {import('socket.io-client').Socket} socket 확인할 클라이언트 소켓 * @returns {Promise} 연결 완료 Promise */ function waitForConnection(socket) { return new Promise((resolve, reject) => { socket.once('connect', resolve) socket.once('connect_error', reject) }) } /** * Socket.IO 승인 응답이 있는 이벤트를 전송한다. * * @param {import('socket.io-client').Socket} socket 이벤트를 전송할 소켓 * @param {string} eventName 이벤트 이름 * @param {unknown[]} eventArguments 이벤트 인자 * @returns {Promise} 서버 승인 응답 */ function emitWithAcknowledgement(socket, eventName, ...eventArguments) { return new Promise((resolve, reject) => { socket.timeout(3000).emit( eventName, ...eventArguments, (timeoutError, response) => { if (timeoutError) { reject(timeoutError) return } resolve(response) }, ) }) } /** * 게임 시작 이벤트를 한 번 기다린다. * * @param {import('socket.io-client').Socket} socket 이벤트를 기다릴 소켓 * @returns {Promise} 게임 시작 정보 */ function waitForGameStart(socket) { return new Promise((resolve) => { socket.once('game:started', resolve) }) } /** * 지정한 Socket.IO 이벤트를 한 번 기다린다. * * @param {import('socket.io-client').Socket} socket 이벤트를 기다릴 소켓 * @param {string} eventName 기다릴 이벤트 이름 * @returns {Promise} 수신한 이벤트 데이터 */ function waitForSocketEvent(socket, eventName) { return new Promise((resolve) => { socket.once(eventName, resolve) }) } /** * 두 클라이언트의 방 생성부터 게임 시작까지 검증한다. * * @returns {Promise} 검증 완료 Promise */ async function runIntegrationTest() { const firstPlayerSocket = io(SOCKET_SERVER_URL, { path: '/socket.io', transports: ['websocket', 'polling'], }) const secondPlayerSocket = io(SOCKET_SERVER_URL, { path: '/socket.io', transports: ['websocket', 'polling'], }) let resumedSecondPlayerSocket = null try { await Promise.all([ waitForConnection(firstPlayerSocket), waitForConnection(secondPlayerSocket), ]) const createRoomResponse = await emitWithAcknowledgement( firstPlayerSocket, 'room:create', ) assert.equal(createRoomResponse.ok, true) assert.equal(createRoomResponse.room.players.length, 1) const joinRoomResponse = await emitWithAcknowledgement( secondPlayerSocket, 'room:join', createRoomResponse.room.roomCode, ) assert.equal(joinRoomResponse.ok, true) assert.equal(joinRoomResponse.room.players.length, 2) const validDeckUrl = createValidDeckUrl() const firstDeckResponse = await emitWithAcknowledgement( firstPlayerSocket, 'player:register-deck', validDeckUrl, ) const secondDeckResponse = await emitWithAcknowledgement( secondPlayerSocket, 'player:register-deck', validDeckUrl, ) assert.equal(firstDeckResponse.ok, true) assert.equal(secondDeckResponse.ok, true) const firstGameStart = waitForGameStart(firstPlayerSocket) const secondGameStart = waitForGameStart(secondPlayerSocket) await emitWithAcknowledgement( firstPlayerSocket, 'player:set-ready', true, ) await emitWithAcknowledgement( secondPlayerSocket, 'player:set-ready', true, ) const [firstStartPayload, secondStartPayload] = await Promise.all([ firstGameStart, secondGameStart, ]) assert.equal(firstStartPayload.seat, 'player-one') assert.equal(secondStartPayload.seat, 'player-two') assert.equal(firstStartPayload.roomCode, secondStartPayload.roomCode) assert.match(firstStartPayload.gameQuery, /series=ua01/) assert.ok( [createRoomResponse.playerId, joinRoomResponse.playerId].includes( firstStartPayload.gameState.firstPlayerId, ), ) const privateDeckInstanceIds = Array.from( { length: 50 }, (_, cardIndex) => `private-card-${cardIndex + 1}`, ) const shuffledDeckResponse = await emitWithAcknowledgement( firstPlayerSocket, 'game:shuffle-private-deck', privateDeckInstanceIds, ) assert.equal(shuffledDeckResponse.ok, true) assert.equal(shuffledDeckResponse.instanceIds.length, 50) assert.equal(new Set(shuffledDeckResponse.instanceIds).size, 50) const drawnDeckResponse = await emitWithAcknowledgement( firstPlayerSocket, 'game:draw-private-deck', 2, ) assert.deepEqual(drawnDeckResponse.instanceIds, [ shuffledDeckResponse.instanceIds[49], shuffledDeckResponse.instanceIds[48], ]) const returnedCardInstanceId = drawnDeckResponse.instanceIds[0] await emitWithAcknowledgement( firstPlayerSocket, 'game:place-private-deck-card', { instanceId: returnedCardInstanceId, placement: 'top', }, ) const redrawnDeckResponse = await emitWithAcknowledgement( firstPlayerSocket, 'game:draw-private-deck', 1, ) assert.equal(redrawnDeckResponse.instanceIds[0], returnedCardInstanceId) await emitWithAcknowledgement( firstPlayerSocket, 'game:confirm-setup', ) const startedTurnStatePromise = waitForSocketEvent( firstPlayerSocket, 'game:state', ) await emitWithAcknowledgement( secondPlayerSocket, 'game:confirm-setup', ) const startedTurnState = await startedTurnStatePromise assert.equal( startedTurnState.currentTurnPlayerId, startedTurnState.firstPlayerId, ) const opponentStatePromise = waitForSocketEvent( secondPlayerSocket, 'game:opponent-state', ) await emitWithAcknowledgement( firstPlayerSocket, 'game:publish-player-state', { revision: 2, cards: [ { instanceId: 'published-card-1', cardNumber: 'TEST-001', imageUrl: '/card-assets/ua01/TEST-001.png', zone: 'field', position: { x: 0.4, y: 0.7 }, isFaceUp: true, isTapped: false, isUntapLocked: false, stackOrder: 10, }, { instanceId: 'hidden-hand-card', cardNumber: 'SECRET-001', imageUrl: '/card-assets/ua01/SECRET-001.png', zone: 'hand', position: { x: 0.5, y: 0.9 }, isFaceUp: true, isTapped: false, isUntapLocked: false, stackOrder: 11, }, ], counts: { deck: 35, hand: 7, life: 7, }, }, ) const opponentState = await opponentStatePromise assert.equal(opponentState.cards.length, 1) assert.equal(opponentState.cards[0].instanceId, 'published-card-1') assert.equal(opponentState.counts.hand, 7) assert.equal(opponentState.revision, 2) const staleStateResponse = await emitWithAcknowledgement( firstPlayerSocket, 'game:publish-player-state', { revision: 1, cards: [], counts: { deck: 0, hand: 0, life: 0, }, }, ) assert.equal(staleStateResponse.ignored, true) assert.equal(staleStateResponse.revision, 2) const currentTurnSocket = startedTurnState.currentTurnPlayerId === createRoomResponse.playerId ? firstPlayerSocket : secondPlayerSocket const nextTurnStatePromise = waitForSocketEvent( firstPlayerSocket, 'game:state', ) const endTurnResponse = await emitWithAcknowledgement( currentTurnSocket, 'game:end-turn', ) const nextTurnState = await nextTurnStatePromise assert.equal(endTurnResponse.ok, true) assert.notEqual( nextTurnState.currentTurnPlayerId, startedTurnState.currentTurnPlayerId, ) secondPlayerSocket.disconnect() resumedSecondPlayerSocket = io(SOCKET_SERVER_URL, { path: '/socket.io', transports: ['websocket', 'polling'], }) await waitForConnection(resumedSecondPlayerSocket) const resumeResponse = await emitWithAcknowledgement( resumedSecondPlayerSocket, 'room:resume', { roomCode: joinRoomResponse.room.roomCode, playerId: joinRoomResponse.playerId, resumeToken: joinRoomResponse.resumeToken, }, ) assert.equal(resumeResponse.ok, true) assert.equal(resumeResponse.playerId, joinRoomResponse.playerId) assert.equal(resumeResponse.room.hasStarted, true) assert.equal( resumeResponse.gameStartPayload.roomCode, joinRoomResponse.room.roomCode, ) assert.match(resumeResponse.gameStartPayload.gameQuery, /series=ua01/) const roomClosedPromise = waitForSocketEvent( firstPlayerSocket, 'room:closed', ) const leaveRoomResponse = await emitWithAcknowledgement( resumedSecondPlayerSocket, 'room:leave', ) const roomClosedInformation = await roomClosedPromise assert.equal(leaveRoomResponse.ok, true) assert.match(roomClosedInformation.message, /방이 종료/) console.log('Socket.IO 2인 게임 통합 테스트를 통과했습니다.') } finally { firstPlayerSocket.disconnect() secondPlayerSocket.disconnect() resumedSecondPlayerSocket?.disconnect() } } await runIntegrationTest()