TDD 기법을 사용해 블록을 해당 위치에 놓을 수 있는지 확인하는 함수 작성을 마무리해보자
오늘 작성할 함수는 요청자의 다른 블록과 맞닿는 변이 있는지 확인하는 함수와 해당 자리에 다른 블록 셀이 있는지 확인하는 함수이다. 각각 hasEdgeConnection
, hasOverlap
이라는 이름으로 구현해보겠다.
hasEdgeConnection()
새롭게 배치하고자 하는 블럭이 요청자가 배치한 이전 블록과 맞닿아있는지 확인하는 함수를 작성하려고 한다. 요구사항을 테스트로 정의해보자. 아래와 같이 케이스를 작성하면 될 것 같다.
// $lib/game.ts
export const hasEdgeConnection = ({ block, board, position, playerIdx }: PlaceBlockDTO) => {
throw new Error('not implemented');
}
// src/tests/game.test.ts
describe('hasEdgeConnection', () => {
let board = createEmptyBoard();
const singleCellBlock: BlockMatrix = getBlockMatrix({ type: '10', rotation: 0, flip: false });
beforeEach(() => {
board = createEmptyBoard();
});
describe('다른 블록과 맞닿아있는 경우', () => {
test('주변에 다른 블록이 전혀 없으면 false를 반환', () => {
const dto: PlaceBlockDTO = {
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 1],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(false);
});
test('주변에 다른 플레이어 블록만 있으면 false를 반환', () => {
placeBlock({
block: singleCellBlock,
position: [1, 0],
board,
playerIdx: 1,
turn: 0,
});
placeBlock({
block: singleCellBlock,
position: [0, 1],
board,
playerIdx: 1,
turn: 0,
});
placeBlock({
block: singleCellBlock,
position: [1, 2],
board,
playerIdx: 1,
turn: 0,
});
placeBlock({
block: singleCellBlock,
position: [2, 1],
board,
playerIdx: 1,
turn: 0,
});
const dto: PlaceBlockDTO = {
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 1],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(false);
});
test('대각 위치에만 해당 플레이어의 블록이 있으면 false를 반환', () => {
placeBlock({
block: singleCellBlock,
position: [0, 0],
board,
playerIdx: 0,
turn: 0,
});
placeBlock({
block: singleCellBlock,
position: [0, 2],
board,
playerIdx: 0,
turn: 0,
});
placeBlock({
block: singleCellBlock,
position: [2, 0],
board,
playerIdx: 0,
turn: 0,
});
placeBlock({
block: singleCellBlock,
position: [2, 2],
board,
playerIdx: 0,
turn: 0,
});
const dto: PlaceBlockDTO = {
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 1],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(false)
});
});
describe('다른 블록과 맞닿아있지 않는 경우', () => {
test('블록 상단에 해당 플레이어의 블록이 있으면 true 반환', () => {
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
});
const dto: PlaceBlockDTO = {
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('블록 우측에 해당 플레이어의 블록이 있으면 true 반환', () => {
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [0, 1],
turn: 0,
});
const dto: PlaceBlockDTO = {
block: singleCellBlock,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('블록 하단에 해당 플레이어의 블록이 있으면 true 반환', () => {
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 0],
turn: 0,
});
const dto: PlaceBlockDTO = {
block: singleCellBlock,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('블록 좌측에 해당 플레이어의 블록이 있으면 true 반환', () => {
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
});
const dto: PlaceBlockDTO = {
block: singleCellBlock,
board,
playerIdx: 0,
position: [0, 1],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
});
describe('복잡한 모양(type 54)의 블록 테스트', () => {
const block: BlockMatrix = getBlockMatrix({ type: '54', rotation: 0, flip: false })
test('(-1, 0)', () => {
placeBlock({
block,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [1, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('(0, 1)', () => {
placeBlock({
block,
board,
playerIdx: 0,
position: [0, 1],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('(0, 2)', () => {
placeBlock({
block,
board,
playerIdx: 0,
position: [0, 2],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('(1, 3)', () => {
placeBlock({
block,
board,
playerIdx: 0,
position: [1, 3],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('(2, 2)', () => {
placeBlock({
block,
board,
playerIdx: 0,
position: [2, 2],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('(3, 1)', () => {
placeBlock({
block,
board,
playerIdx: 0,
position: [3, 1],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('(2, 0)', () => {
placeBlock({
block,
board,
playerIdx: 0,
position: [2, 0],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('(1, -1)', () => {
placeBlock({
block,
board,
playerIdx: 0,
position: [1, 0],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [0, 1],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
test('(0, -1)', () => {
placeBlock({
block,
board,
playerIdx: 0,
position: [0, 0],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [0, 1],
turn: 0,
};
const result = hasEdgeConnection(dto);
expect(result).toBe(true);
});
});
});
실패하는 테스트를 모두 작성하였다. 이제 기능 구현을 진행해보자.
아래 순서로 구현하는 것이 좋아보인다. 아무래도 그냥 블록을 순회하여 검사하는 것이 hasDiagonalConnection
에서 구현한 것처럼 필요한 셀들을 추출하는 것보다 효율적일 것 같다. 순회 방식은 DFS/BFS 등 탐색 알고리즘이나 단순 순회 중에서 고민해봤는데 단순히 순회하는 편이 더 복잡도가 낮을 것이라 판단했다. 구현은 다음과 같이 진행할 것이다.
Array.prototype.some
함수를 통해 블록 배열을 순회대각 연결 확인보다 확실히 구현이 쉽겠다.
export const hasEdgeConnection = ({ block, board, playerIdx, position }: PlaceBlockDTO) => {
const [row, col] = position;
const boardSize = 20;
return block.some((blockLine, blockRow) =>
blockLine.some((blockCell, blockCol) => {
if (!blockCell) return false;
const boardRow = blockRow + row;
const boardCol = blockCol + col;
return (
(boardRow > 0 && board[boardRow - 1][boardCol] === playerIdx)
|| (boardRow < boardSize - 1 && board[boardRow + 1][boardCol] === playerIdx)
|| (boardCol > 0 && board[boardRow][boardCol - 1] === playerIdx)
|| (boardCol < boardSize - 1 && board[boardRow][boardCol + 1] === playerIdx)
);
})
);
};
Array.prototype.some
은 요소를 순회 중 true가 return되면 순회를 멈춘다. 이에 for statement와 성능 차이가 크지 않을 것이라 판단하여 해당 메서드를 사용했다.
모든 테스트를 문제 없이 통과했다. 이제 마지막으로 해당 자리에 다른 블록 셀이 있는지 확인하는 함수를 작성해보겠다.
hasOverlap
테스트 케이스는 아래와 같이 작성해보겠다.
describe('hasOverlap', () => {
let board = createEmptyBoard();
const singleCellBlock: BlockMatrix = getBlockMatrix({ type: '10', rotation: 0, flip: false });
beforeEach(() => {
board = createEmptyBoard();
});
describe('1x1 블록의 충돌 테스트', () => {
test('셀에 다른 블록이 존재하면 true 반환', () => {
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 1],
turn: 0,
});
const dto: PlaceBlockDTO = {
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 1],
turn: 1,
};
const result = hasOverlap(dto);
expect(result).toBe(true);
});
test('셀에 다른 블록이 존재하지 않으면 false 반환', () => {
const dto: PlaceBlockDTO = {
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 1],
turn: 1,
};
const result = hasOverlap(dto);
expect(result).toBe(false);
});
});
describe('복잡한 모양(type 54)의 블록 테스트', () => {
const block = getBlockMatrix({
type: '54',
rotation: 0,
flip: false,
});
test('블록의 빈 공간에만 다른 블록이 존재하면 false 반환', () => {
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 2],
turn: 0,
});
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 3],
turn: 0,
});
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [3, 1],
turn: 0,
});
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [3, 3],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [1, 1],
turn: 0,
};
const result = hasOverlap(dto);
expect(result).toBe(false);
});
test('블록의 셀에만 다른 블록이 존재하면 true 반환', () => {
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [1, 1],
turn: 0,
});
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [2, 1],
turn: 0,
});
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [2, 2],
turn: 0,
});
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [2, 3],
turn: 0,
});
placeBlock({
block: singleCellBlock,
board,
playerIdx: 0,
position: [3, 2],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [1, 1],
turn: 0,
};
const result = hasOverlap(dto);
expect(result).toBe(true);
});
test('블록의 빈 공간과 셀 모두에 다른 블록이 존재하면 true 반환', () => {
const randomBlock = getBlockMatrix({
type: '58',
rotation: 1,
flip: false,
});
placeBlock({
block: randomBlock,
board,
playerIdx: 0,
position: [1, 1],
turn: 0,
});
const dto: PlaceBlockDTO = {
block,
board,
playerIdx: 0,
position: [1, 1],
turn: 0,
};
const result = hasOverlap(dto);
expect(result).toBe(true);
});
});
});
이렇게 실패하는 테스트를 먼저 작성하였다. 이제 요구사항에 맞춰 함수를 구현해보자.
간단하게, 파라미터 position
과 블록의 위치에 기반해 보드의 해당 셀이 false가 아닌지 확인하면 되겠다.
export const hasOverlap = ({ block, board, position }: PlaceBlockDTO) => {
const [row, col] = position;
return block.some((blockLine, blockRow) =>
blockLine.some((cell, blockCol) => {
if (!cell) return false;
const boardRow = blockRow + row;
const boardCol = blockCol + col;
return board[boardRow][boardCol] !== false;
})
);
};
성공적으로 테스트를 통과하였다!
우선 마무리하기 전 테스트 케이스들에서 공통적으로 사용하는 로직들을 통합하겠다.
describe('isBlockPlaceable 내부 로직 검사', () => {
const createEmptyBoard = (): BoardMatrix =>
Array(20).fill(undefined).map(() => Array(20).fill(false));
let board = createEmptyBoard();
beforeEach(() => {
board = createEmptyBoard();
});
const singleCellBlock: BlockMatrix = getBlockMatrix({ type: '10', rotation: 0, flip: false });
const complexBlock: BlockMatrix = getBlockMatrix({ type: '54', rotation: 0, flip: false });
...
이후 대부분의 테스트에서 사용하는 block
은 complexBlock
으로 이름을 변경하였고, beforeEach
를 통해 보드를 초기화하는 로직은 최상위에만 남기고 나머지는 모두 제거하였다.
또한, isBlockPlaceable
함수 내부에서 조건에 맞지 않으면 boolean 타입의 false를 반환했는데 이를 result, reason을 return하도록 수정하여 왜 해당 블록을 놓을 수 없는지 사용자에게 반환할 수 있도록 수정하겠다. 이를 반영해 여지껏 작성한 함수들을 정리하여 아래와 같이 putBlockOnBoard
및 isBlockPlaceable
을 수정할 수 있겠다.
export const isBlockPlaceable = ({ block, position, board, playerIdx, turn }: PlaceBlockDTO): { result: boolean, reason?: string } => {
if (!isWithinBoardBounds({ block, position, board, playerIdx, turn })) {
return { result: false, reason: 'bound' };
}
if (!isFirstMoveValid({ block, position, board, playerIdx, turn })) {
return { result: false, reason: 'invalid first move' };
}
if (!hasDiagonalConnection({ block, position, board, playerIdx, turn })) {
return { result: false, reason: 'no connection' };
}
if (hasEdgeConnection({ block, position, board, playerIdx, turn })) {
return { result: false, reason: 'connected with other block' };
}
if (hasOverlap({ block, position, board, playerIdx, turn })) {
return { result: false, reason: 'overlapped' };
}
return { result: true };
};
export const putBlockOnBoard = ({ board, blockInfo, position, playerIdx, turn }: PutBlockDTO) => {
const block = getBlockMatrix(blockInfo);
const { result, reason } = isBlockPlaceable({ block, position, board, playerIdx, turn })
if (!result) {
return reason;
}
placeBlock({ block, position, board, playerIdx, turn });
};
진작 TDD 기법을 사용하지 않은 것이 후회된다. "고작 게임 로직 정도에 테스트를?" 정도로 생각했는데, 테스트 케이스를 작성하며 구현할 기능에 대한 그림을 머리 속에 대략적으로 그리게 되니 오히려 좋았다. 테스트 자체는 나중에 함수를 다시 구현하게 되면 유용하게 사용할 수 있을 것 같다.
게임 로직 리팩터링은 해당 편에서 마무리하고, 이후 SvelteKit과 웹소켓(ws)을 사용하여 게임을 웹 서비스로 구현해보도록 하겠다.