diff --git a/packages/abalone-engine/package.json b/packages/abalone-engine/package.json index 3b9bf127..e00f5bba 100644 --- a/packages/abalone-engine/package.json +++ b/packages/abalone-engine/package.json @@ -19,9 +19,11 @@ "./types": "./src/types.ts" }, "scripts": { - "test": "vitest run" + "test": "vitest run", + "test:coverage": "vitest run --coverage" }, "devDependencies": { + "@vitest/coverage-v8": "4.1.12", "typescript": "5.9.3", "vitest": "4.1.12" } diff --git a/packages/abalone-engine/src/game-state.test.ts b/packages/abalone-engine/src/game-state.test.ts index b5414059..b927c331 100644 --- a/packages/abalone-engine/src/game-state.test.ts +++ b/packages/abalone-engine/src/game-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import type { SetupKey } from "#abalone-engine/board-setups" -import type { GameState } from "#abalone-engine/game-state" +import type { GameState, MoveDetails } from "#abalone-engine/game-state" import { canNextMove, canPrevMove, @@ -107,6 +107,14 @@ describe("isValidSelection", () => { expect(isValidSelection(state, "0,0")).toBe(false) expect(isValidSelection(resignGame(state), "4,0")).toBe(false) }) + + it("treats a bot game with no colour recorded as the human playing black", () => { + const unset = { + ...createGameState("standard", "white", "ai"), + playerColor: undefined as unknown as GameState["playerColor"], + } + expect(isValidSelection(unset, "4,0")).toBe(true) + }) }) describe("makeMove", () => { @@ -236,6 +244,29 @@ describe("walking through history", () => { expect(goToMove(played, -1)).toBe(played) expect(goToMove(played, 99)).toBe(played) }) + + //only makeMove writes history, so these entries are hand-edited: the board + //must still restore and simply skip a highlight it cannot work out + it("restores an entry whose move it cannot draw", () => { + const withDetails = (patch: Partial): GameState => { + const history = [...played.moveHistory] + const details = history[1].moveDetails as MoveDetails + history[1] = { ...history[1], moveDetails: { ...details, ...patch } } + return { ...played, moveHistory: history } + } + + const noMarbles = goToMove(withDetails({ marbles: [] }), 1) + expect(noMarbles.black.has("1,0")).toBe(true) + expect(noMarbles.lastMove).toBeNull() + + const farDestination = withDetails({ destination: "-4,0" }) + expect(goToMove(farDestination, 1).lastMove).toBeNull() + + const noShoved = withDetails({ + shovedMarbles: undefined as unknown as string[], + }) + expect(goToMove(noShoved, 1).lastMove?.shovedMarbles).toEqual([]) + }) }) describe("truncateToMove", () => { @@ -278,6 +309,15 @@ describe("undoTargetIndex", () => { expect(undoTargetIndex(ai)).toBe(2) }) + it("takes back as black when the bot game has no colour recorded", () => { + const ai = play(createGameState("standard", "white", "ai"), SHUFFLE) + const unset = { + ...ai, + playerColor: undefined as unknown as GameState["playerColor"], + } + expect(undoTargetIndex(unset)).toBe(2) + }) + it("has nothing to take back at the start or before the player has moved", () => { expect(undoTargetIndex(createGameState())).toBe(-1) const botOpened = play( diff --git a/packages/abalone-engine/src/moves.test.ts b/packages/abalone-engine/src/moves.test.ts index e03f7c0d..ca891a2f 100644 --- a/packages/abalone-engine/src/moves.test.ts +++ b/packages/abalone-engine/src/moves.test.ts @@ -85,6 +85,16 @@ describe("destinationsFor — plain moves", () => { expect(new Set(backward)).toEqual(new Set(forward)) }) + it("offers a line across the rows in one order whichever end leads", () => { + //the step from "1,0" to "0,0" climbs a row, which CANONICAL_HEADING flips, + //so this reading takes the other branch from the one above + const black = ["0,0", "1,0"] + const downward = destinations(black, [], ["0,0", "1,0"]) + const upward = destinations(black, [], ["1,0", "0,0"]) + expect(upward).toEqual(downward) + expect(upward).toHaveLength(6) + }) + it("blocks a broadside when any landing square is taken", () => { const found = destinations(["0,0", "0,1"], ["1,0"], ["0,0", "0,1"]) expect(found).not.toContain("1,1") diff --git a/packages/abalone-engine/src/rules.test.ts b/packages/abalone-engine/src/rules.test.ts index 5f2b3c1e..5740352c 100644 --- a/packages/abalone-engine/src/rules.test.ts +++ b/packages/abalone-engine/src/rules.test.ts @@ -73,6 +73,15 @@ describe("selectMarble", () => { selectMarble(board, ["0,0", "0,1", "0,2"], "1,0", "black"), ).toEqual(["1,0"]) }) + + it("grows a white line and starts over on black", () => { + const whites = boardOf(["1,1"], ["0,3", "0,4"]) + expect(selectMarble(whites, ["0,3"], "0,4", "white")).toEqual([ + "0,3", + "0,4", + ]) + expect(selectMarble(whites, ["0,3"], "1,1", "black")).toEqual(["1,1"]) + }) }) describe("deselectMarble", () => { @@ -104,6 +113,14 @@ describe("selectRun", () => { expect(selectRun(board, "1,1", "0,0")).toBeNull() expect(selectRun(board, "0,0", "9,9")).toBeNull() }) + + it("returns null when the run would leave the board on the way", () => { + //from the rim the walk runs off the board on several headings before it + //finds the reach, and must give up on each rather than wrap around + const rim = boardOf(["0,4", "0,3", "0,2"], []) + expect(selectRun(rim, "0,4", "0,2")).toEqual(["0,4", "0,3", "0,2"]) + expect(selectRun(rim, "0,4", "-4,4")).toBeNull() + }) }) describe("getPossibleMoves", () => { diff --git a/packages/abalone-engine/vitest.config.ts b/packages/abalone-engine/vitest.config.ts index a19f7b0d..65c8dede 100644 --- a/packages/abalone-engine/vitest.config.ts +++ b/packages/abalone-engine/vitest.config.ts @@ -13,5 +13,20 @@ export default defineConfig({ }, test: { environment: "node", + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + reporter: ["text", "json-summary"], + //today's numbers rounded down: the engine holds every rule of the game, + //so a change that ships an untested branch here fails the CI gate, which + //runs this script instead of the plain test one + thresholds: { + lines: 99, + branches: 98, + functions: 100, + statements: 99, + }, + }, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3dc9838..a3b2b10f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: packages/abalone-engine: devDependencies: + '@vitest/coverage-v8': + specifier: 4.1.12 + version: 4.1.12(vitest@4.1.12) typescript: specifier: 5.9.3 version: 5.9.3