diff --git a/eslint.config.js b/eslint.config.js index 1d736e7..b64a17a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,4 +15,18 @@ export default tsEslint.config( }, }, }, + { + rules: { + "@typescript-eslint/restrict-template-expressions": [ + "error", + { allowNumber: true, allowNever: true }, + ], + }, + }, + { + files: ["**/*.spec.*"], + rules: { + "@typescript-eslint/require-await": "off", + }, + }, ); diff --git a/src/lib/board.spec.ts b/src/lib/board.spec.ts new file mode 100644 index 0000000..7cac5c2 --- /dev/null +++ b/src/lib/board.spec.ts @@ -0,0 +1,63 @@ +import t, { type Test } from "tap"; +import { Board } from "./board.ts"; +import { SquareState } from "./types.js"; + +const createAndCheckBoard = (t: Test, serialized: string) => { + const board = new Board(serialized); + t.equal(board.serialize(), serialized); + return board; +}; + +void t.test("0x0 board", async (t) => { + const board = createAndCheckBoard(t, ""); + t.throws(() => board.get(0, 0), { message: "Out of bounds: 0:0" }); + t.throws(() => board.get(0, 1), { message: "Out of bounds: 0:1" }); + t.throws(() => board.get(1, 0), { message: "Out of bounds: 1:0" }); + t.throws(() => board.get(1, 1), { message: "Out of bounds: 1:1" }); +}); + +void t.test("Empty 1x1 board", async (t) => { + const board = createAndCheckBoard(t, "_"); + t.equal(board.get(0, 0), SquareState.Unoccupied); + t.throws(() => board.get(0, 1), { message: "Out of bounds: 0:1" }); + t.throws(() => board.get(1, 0), { message: "Out of bounds: 1:0" }); + t.throws(() => board.get(1, 1), { message: "Out of bounds: 1:1" }); +}); + +void t.test("1x1 board with X", async (t) => { + const board = createAndCheckBoard(t, "X"); + t.equal(board.get(0, 0), SquareState.X); + t.throws(() => board.get(0, 1), { message: "Out of bounds: 0:1" }); + t.throws(() => board.get(1, 0), { message: "Out of bounds: 1:0" }); + t.throws(() => board.get(1, 1), { message: "Out of bounds: 1:1" }); +}); + +void t.test("Half-full 3x4 board", async (t) => { + const board = createAndCheckBoard(t, "XO_|O_X|OX_|_XO"); + t.equal(board.get(0, 0), SquareState.X); + t.equal(board.get(0, 1), SquareState.O); + t.equal(board.get(0, 2), SquareState.Unoccupied); + t.throws(() => board.get(0, 3), { message: "Out of bounds: 0:3" }); + t.equal(board.get(1, 0), SquareState.O); + t.equal(board.get(1, 1), SquareState.Unoccupied); + t.equal(board.get(1, 2), SquareState.X); + t.throws(() => board.get(1, 3), { message: "Out of bounds: 1:3" }); + t.equal(board.get(2, 0), SquareState.O); + t.equal(board.get(2, 1), SquareState.X); + t.equal(board.get(2, 2), SquareState.Unoccupied); + t.throws(() => board.get(2, 3), { message: "Out of bounds: 2:3" }); + t.equal(board.get(3, 0), SquareState.Unoccupied); + t.equal(board.get(3, 1), SquareState.X); + t.equal(board.get(3, 2), SquareState.O); + t.throws(() => board.get(3, 3), { message: "Out of bounds: 3:3" }); + t.throws(() => board.get(4, 0), { message: "Out of bounds: 4:0" }); + t.throws(() => board.get(4, 1), { message: "Out of bounds: 4:1" }); + t.throws(() => board.get(4, 2), { message: "Out of bounds: 4:2" }); + t.throws(() => board.get(4, 3), { message: "Out of bounds: 4:3" }); +}); + +void t.test("Throws error on incorrect serialized value", async (t) => { + t.throws(() => new Board("abc"), { + message: "Unsupported square character: a", + }); +}); diff --git a/src/lib/board.ts b/src/lib/board.ts new file mode 100644 index 0000000..3b1dbe2 --- /dev/null +++ b/src/lib/board.ts @@ -0,0 +1,57 @@ +import { SquareState } from "./types.ts"; +import { unreachable } from "./utils.ts"; + +export class Board { + private readonly state: SquareState[][]; + + constructor(serialized: string) { + this.state = serialized.split("|").map((line) => + line.split("").map((char) => { + switch (char) { + case "_": + return SquareState.Unoccupied; + case "O": + return SquareState.O; + case "X": + return SquareState.X; + default: + throw new Error(`Unsupported square character: ${char}`); + } + }), + ); + } + + get(row: number, column: number) { + const result = this.state[row]?.[column]; + if (!result) { + throw new Error(`Out of bounds: ${row}:${column}`); + } + + return result; + } + + serialize() { + return this.state + .map((row) => + row + .map((squareState) => { + switch (squareState) { + case SquareState.Unoccupied: + return "_"; + case SquareState.O: + return "O"; + case SquareState.X: + return "X"; + /* c8 ignore start */ + default: + throw new Error( + `Unsupported square state: ${unreachable(squareState)}`, + ); + /* c8 ignore stop */ + } + }) + .join(""), + ) + .join("|"); + } +} diff --git a/src/lib/stub.spec.ts b/src/lib/stub.spec.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/stub.ts b/src/lib/stub.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..048c923 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,5 @@ +export enum SquareState { + Unoccupied = 1, // so that all SquareState values are truthy + X, + O, +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..52ea880 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,4 @@ +// To simplify switches and provide some typecheck-time guarantees +/* c8 ignore start */ +export const unreachable = (value: never) => value as unknown as string; +/* c8 ignore stop */ diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..c8fea9c --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.spec.*"], + "compilerOptions": { + "module": "Preserve" + } + } + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 67d5a42..8dcaefa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,11 @@ { - "extends": "@tsconfig/strictest", - "include": ["src/**/*"], - "compilerOptions": { - "target": "ES2020", - "module": "Preserve", - "forceConsistentCasingInFileNames": true - } + "extends": "@tsconfig/strictest", + "include": ["src/**/*"], + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true, + "target": "ES2020", + "module": "NodeNext", + "forceConsistentCasingInFileNames": true } - \ No newline at end of file +}