diff --git a/src/shared/array-utils.spec.ts b/src/shared/array-utils.spec.ts new file mode 100644 index 0000000..ffa37b4 --- /dev/null +++ b/src/shared/array-utils.spec.ts @@ -0,0 +1,14 @@ +import t from "tap"; +import { repeat, sequence } from "./array-utils.ts"; + +void t.test("repeat", async (t) => { + t.matchOnlyStrict(repeat("x", 0), []); + t.matchOnlyStrict(repeat("x", 1), ["x"]); + t.matchOnlyStrict(repeat("x", 5), ["x", "x", "x", "x", "x"]); +}); + +void t.test("sequence", async (t) => { + t.matchOnlyStrict(sequence(0), []); + t.matchOnlyStrict(sequence(1), [0]); + t.matchOnlyStrict(sequence(5), [0, 1, 2, 3, 4]); +}); diff --git a/src/shared/board.spec.ts b/src/shared/board.spec.ts index 1afe1d7..2b027ef 100644 --- a/src/shared/board.spec.ts +++ b/src/shared/board.spec.ts @@ -89,20 +89,6 @@ void t.test("Serialize / deserialize", async (t) => { t.throws(() => Board.fromSerialized("abc"), { message: "Unsupported square character: a", }); - - { - const board = new Board([[undefined as any]]); - t.throws(() => board.serialize(), { - message: "Unsupported square state: undefined", - }); - } - - { - const board = new Board([["test" as any]]); - t.throws(() => board.serialize(), { - message: "Unsupported square state: test", - }); - } }); }); diff --git a/src/shared/board.ts b/src/shared/board.ts index 1d5f6d7..e7e6327 100644 --- a/src/shared/board.ts +++ b/src/shared/board.ts @@ -3,7 +3,7 @@ import { BoardType, SquareState, formatSquareState, parseSquareState } from "./d export class Board implements BoardType { // State should be immutable - constructor( + private constructor( private readonly state: SquareState[][], private serialized?: string, ) {} @@ -61,20 +61,7 @@ export class Board implements BoardType { } private forceSerialize() { - return this.state - .map((row) => - row - .map((squareState) => { - const char = formatSquareState(squareState); - if (!char) { - throw new Error(`Unsupported square state: ${squareState}`); - } - - return char; - }) - .join(""), - ) - .join("|"); + return this.state.map((row) => row.map(formatSquareState).join("")).join("|"); } serialize() { diff --git a/src/shared/boardgame-state.spec.ts b/src/shared/boardgame-state.spec.ts new file mode 100644 index 0000000..dc9acb5 --- /dev/null +++ b/src/shared/boardgame-state.spec.ts @@ -0,0 +1,238 @@ +import t, { type Test } from "tap"; + +import { BoardgameState } from "./boardgame-state.ts"; +import { Player, SquareState } from "./datatypes.ts"; + +const createAndCheckBoardgameState = (t: Test, serialized: string) => { + const boardgameState = BoardgameState.fromSerialized(serialized); + t.ok(boardgameState); + t.equal(boardgameState?.serialize(), serialized); + return boardgameState; +}; + +void t.test("Serialize / deserialize", async (t) => { + void t.test("Empty state", async (t) => { + t.equal(BoardgameState.fromSerialized(""), null); + t.equal(BoardgameState.fromSerialized(null), null); + t.equal(BoardgameState.fromSerialized(".."), null); + }); + + void t.test("1x1 state (incompletely serialized)", async (t) => { + const state = BoardgameState.fromSerialized("1x1"); + t.ok(state); + t.equal(state?.serialize(), "1x1.."); + t.equal(state?.rows, 1); + t.equal(state?.columns, 1); + t.equal(state?.currentPlayer, null); + t.equal(state?.currentPlayerName, ""); + t.equal(state?.board, null); + }); + + void t.test("1x1 state with empty board", async (t) => { + const state = createAndCheckBoardgameState(t, "1x1.."); + t.equal(state?.rows, 1); + t.equal(state?.columns, 1); + t.equal(state?.currentPlayer, null); + t.equal(state?.currentPlayerName, ""); + t.equal(state?.board, null); + }); + + void t.test("1x1 board with started game", async (t) => { + const state = createAndCheckBoardgameState(t, "1x1.X._"); + t.equal(state?.rows, 1); + t.equal(state?.columns, 1); + t.equal(state?.currentPlayer, Player.X); + t.equal(state?.currentPlayerName, "X"); + t.equal(state?.board?.hasRow(0), true); + t.equal(state?.board?.hasSquare(0, 0), true); + t.equal(state?.board?.get(0, 0), SquareState.Unoccupied); + t.equal(state?.board?.hasSquare(0, 1), false); + t.equal(state?.board?.hasRow(1), false); + }); + + void t.test("1x2 board with first move", async (t) => { + const state = createAndCheckBoardgameState(t, "1x2.O._X"); + t.equal(state?.rows, 1); + t.equal(state?.columns, 2); + t.equal(state?.currentPlayer, Player.O); + t.equal(state?.currentPlayerName, "O"); + t.equal(state?.board?.get(0, 0), SquareState.Unoccupied); + t.equal(state?.board?.get(0, 1), SquareState.X); + t.equal(state?.board?.hasSquare(0, 2), false); + t.equal(state?.board?.hasRow(1), false); + }); + + void t.test("2x1 board with first move", async (t) => { + const state = createAndCheckBoardgameState(t, "2x1.O._|X"); + t.equal(state?.rows, 2); + t.equal(state?.columns, 1); + t.equal(state?.currentPlayer, Player.O); + t.equal(state?.currentPlayerName, "O"); + t.equal(state?.board?.get(0, 0), SquareState.Unoccupied); + t.equal(state?.board?.get(1, 0), SquareState.X); + t.equal(state?.board?.hasSquare(0, 1), false); + t.equal(state?.board?.hasRow(2), false); + }); +}); + +void t.test("Empty state creation", async (t) => { + void t.test("0x0 board", async (t) => { + const state = BoardgameState.createWithoutBoard(0, 0); + t.equal(state.serialize(), "0x0.."); + t.equal(state.rows, 0); + t.equal(state.columns, 0); + t.equal(state.currentPlayer, null); + t.equal(state.currentPlayerName, ""); + t.equal(state.board, null); + }); + + void t.test("1x0 board", async (t) => { + const state = BoardgameState.createWithoutBoard(1, 0); + t.equal(state.serialize(), "1x0.."); + t.equal(state.rows, 1); + t.equal(state.columns, 0); + t.equal(state.currentPlayer, null); + t.equal(state.currentPlayerName, ""); + t.equal(state.board, null); + }); + + void t.test("1x1 board", async (t) => { + const state = BoardgameState.createWithoutBoard(1, 1); + t.equal(state.serialize(), "1x1.."); + t.equal(state.rows, 1); + t.equal(state.columns, 1); + t.equal(state.currentPlayer, null); + t.equal(state.currentPlayerName, ""); + t.equal(state.board, null); + }); + + void t.test("1x2 board", async (t) => { + const state = BoardgameState.createWithoutBoard(1, 2); + t.equal(state.serialize(), "1x2.."); + t.equal(state.rows, 1); + t.equal(state.columns, 2); + t.equal(state.currentPlayer, null); + t.equal(state.currentPlayerName, ""); + t.equal(state.board, null); + }); + + void t.test("Error handling", async (t) => { + t.throws(() => BoardgameState.fromSerialized("0.."), { + message: "Incorrect dimensions", + }); + }); +}); + +void t.test("withEmptyBoard", async (t) => { + void t.test("On 0x0 board", async (t) => { + const oldState = createAndCheckBoardgameState(t, "0x0.."); + const newState = oldState?.withEmptyBoard(); + t.equal(newState?.serialize(), "0x0.X."); + t.equal(newState?.rows, 0); + t.equal(newState?.columns, 0); + t.equal(newState?.currentPlayer, Player.X); + t.equal(newState?.currentPlayerName, "X"); + t.equal(newState?.board?.serialize(), ""); + }); + + void t.test("On 0x1 board", async (t) => { + const oldState = createAndCheckBoardgameState(t, "0x1.."); + const newState = oldState?.withEmptyBoard(); + t.equal(newState?.serialize(), "0x1.X."); + t.equal(newState?.rows, 0); + t.equal(newState?.columns, 1); + t.equal(newState?.currentPlayer, Player.X); + t.equal(newState?.currentPlayerName, "X"); + t.equal(newState?.board?.serialize(), ""); + }); + + void t.test("On 1x0 board", async (t) => { + const oldState = createAndCheckBoardgameState(t, "1x0.."); + const newState = oldState?.withEmptyBoard(); + t.equal(newState?.serialize(), "1x0.X."); + t.equal(newState?.rows, 1); + t.equal(newState?.columns, 0); + t.equal(newState?.currentPlayer, Player.X); + t.equal(newState?.currentPlayerName, "X"); + t.equal(newState?.board?.serialize(), ""); + }); + + void t.test("On 1x1 board", async (t) => { + const oldState = createAndCheckBoardgameState(t, "1x1.."); + const newState = oldState?.withEmptyBoard(); + t.equal(newState?.serialize(), "1x1.X._"); + t.equal(newState?.rows, 1); + t.equal(newState?.columns, 1); + t.equal(newState?.currentPlayer, Player.X); + t.equal(newState?.currentPlayerName, "X"); + t.equal(newState?.board?.serialize(), "_"); + }); + + void t.test("On 1x2 board", async (t) => { + const oldState = createAndCheckBoardgameState(t, "1x2.."); + const newState = oldState?.withEmptyBoard(); + t.equal(newState?.serialize(), "1x2.X.__"); + t.equal(newState?.rows, 1); + t.equal(newState?.columns, 2); + t.equal(newState?.currentPlayer, Player.X); + t.equal(newState?.currentPlayerName, "X"); + t.equal(newState?.board?.serialize(), "__"); + }); + + void t.test("On 2x1 board", async (t) => { + const oldState = createAndCheckBoardgameState(t, "2x1.."); + const newState = oldState?.withEmptyBoard(); + t.equal(newState?.serialize(), "2x1.X._|_"); + t.equal(newState?.rows, 2); + t.equal(newState?.columns, 1); + t.equal(newState?.currentPlayer, Player.X); + t.equal(newState?.currentPlayerName, "X"); + t.equal(newState?.board?.serialize(), "_|_"); + }); +}); + +void t.test("withMove", async (t) => { + void t.test("On 2x3 board", async (t) => { + const oldState = createAndCheckBoardgameState(t, "2x3.X.___|___"); + + const firstMoveState = oldState?.withMove(0, 1); + t.equal(firstMoveState?.serialize(), "2x3.O._X_|___"); + t.equal(firstMoveState?.rows, 2); + t.equal(firstMoveState?.columns, 3); + t.equal(firstMoveState?.currentPlayer, Player.O); + t.equal(firstMoveState?.currentPlayerName, "O"); + t.equal(firstMoveState?.board?.serialize(), "_X_|___"); + + const secondMoveState = firstMoveState?.withMove(1, 2); + t.equal(secondMoveState?.serialize(), "2x3.X._X_|__O"); + t.equal(secondMoveState?.rows, 2); + t.equal(secondMoveState?.columns, 3); + t.equal(secondMoveState?.currentPlayer, Player.X); + t.equal(secondMoveState?.currentPlayerName, "X"); + t.equal(secondMoveState?.board?.serialize(), "_X_|__O"); + }); + + void t.test("Error handling", async () => { + const unstartedBoard = createAndCheckBoardgameState(t, "1x1.."); + t.throws(() => unstartedBoard?.withMove(0, 0), { + message: "Game is not started", + }); + }); +}); + +void t.test("Sample usage scenario", async (t) => { + const noBoardState = BoardgameState.createWithoutBoard(2, 3); + t.equal(noBoardState.serialize(), "2x3.."); + + const gameStartedState = noBoardState.withEmptyBoard(); + t.equal(gameStartedState.serialize(), "2x3.X.___|___"); + + const firstMoveState = gameStartedState.withMove(0, 1); + t.equal(firstMoveState.serialize(), "2x3.O._X_|___"); + + const secondMoveState = firstMoveState.withMove(1, 2); + t.equal(secondMoveState.serialize(), "2x3.X._X_|__O"); + + const thirdMoveState = secondMoveState.withMove(1, 0); + t.equal(thirdMoveState.serialize(), "2x3.O._X_|X_O"); +}); diff --git a/src/shared/datatypes.spec.ts b/src/shared/datatypes.spec.ts index f2c70c5..a506cad 100644 --- a/src/shared/datatypes.spec.ts +++ b/src/shared/datatypes.spec.ts @@ -4,11 +4,15 @@ import { FinalOutcome, Player, SquareState, + formatPlayer, + formatSquareState, getDesiredFinalOutcomeByPlayer, getExpectedOutcomeByCurrentOutcome, getNextPlayer, getOccupiedStateByPlayer, getUndesiredFinalOutcomeByPlayer, + parsePlayer, + parseSquareState, } from "./datatypes.ts"; // These unit tests are mostly useless because they're basically a tautology, @@ -71,3 +75,35 @@ void t.test("getUndesiredFinalOutcomeByPlayer", async (t) => { message: "Unsupported player: test", }); }); + +void t.test("formatSquareState", async (t) => { + t.equal(formatSquareState(SquareState.Unoccupied), "_"); + t.equal(formatSquareState(SquareState.X), "X"); + t.equal(formatSquareState(SquareState.O), "O"); + t.throws(() => formatSquareState("test" as unknown as SquareState), { + message: "Unsupported square state: test", + }); +}); + +void t.test("parseSquareState", async (t) => { + t.equal(parseSquareState("_"), SquareState.Unoccupied); + t.equal(parseSquareState("X"), SquareState.X); + t.equal(parseSquareState("O"), SquareState.O); + t.equal(parseSquareState("test"), null); + t.equal(parseSquareState(undefined as unknown as string), null); +}); + +void t.test("formatPlayer", async (t) => { + t.equal(formatPlayer(Player.X), "X"); + t.equal(formatPlayer(Player.O), "O"); + t.throws(() => formatPlayer("test" as unknown as Player), { + message: "Unsupported player: test", + }); +}); + +void t.test("parsePlayer", async (t) => { + t.equal(parsePlayer("X"), Player.X); + t.equal(parsePlayer("O"), Player.O); + t.equal(parsePlayer("test"), null); + t.equal(parsePlayer(undefined as unknown as string), null); +}); diff --git a/src/shared/datatypes.ts b/src/shared/datatypes.ts index 2f9e4ca..74ffa0d 100644 --- a/src/shared/datatypes.ts +++ b/src/shared/datatypes.ts @@ -1,4 +1,4 @@ -import { unreachableNull, unreachableString } from "./utils.ts"; +import { unreachableString } from "./utils.ts"; export enum SquareState { Unoccupied = 1, // so that all SquareState values are truthy @@ -128,7 +128,7 @@ export const formatSquareState = (squareState: SquareState) => { case SquareState.X: return "X"; default: - return unreachableNull(squareState); + throw new Error(`Unsupported square state: ${unreachableString(squareState)}`); } }; diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 1bfb952..f8aac13 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,5 +1,2 @@ // To simplify switches and provide some typecheck-time guarantees export const unreachableString = (value: never) => value as unknown as string; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- We don't care about the value here, but we do care that from TS point of view it has type `never` -export const unreachableNull = (_value: never) => null;