From 13f828c008e0abc7229d2d8750bd6d1f58095edd Mon Sep 17 00:00:00 2001 From: Ken Riley Date: Wed, 15 Jul 2026 10:36:42 -0600 Subject: [PATCH] test(game): coverage gate for decomposed Game modules (Phase 8 of #134) Add targeted tests for the decomposed modules and a COVERAGE=1-gated threshold scoped to src/Game/: - guards.ts -> 100% (canRoll/canRollForStart/canPlayerRoll/canGetPossibleMoves) - robot.ts -> 88% (handleRobotMovedState, confirmTurnWithRobotAutomation) - game-accessors: activePlayer/inactivePlayer/getPlayersForColor/findChecker + createNewGame rules merge - turnFlow: roll (rolled-for-start/rolling/doubled), switchDice, executeAndRecalculate, checkAndCompleteTurn, toMoved, moveAndFinalize - cube: acceptDouble-at-64 (maxxed) and refuseDouble non-first-double Game/ aggregate coverage: ~59% -> 80.5% stmts, 71.8% branch, 89% funcs, 82.5% lines. Gate uses a directory-path key (aggregate, not per-file) at 75/70/75/75; branch floor ratchets toward 75 as turnFlow no-move/blocked paths and the gnuPositionId getter gain coverage. Enforced only under COVERAGE=1, so normal jest runs are unaffected. Core: 518 passed / 12 skipped; COVERAGE=1 exit 0; normal exit 0. Refs #134 --- jest.config.js | 15 ++ .../cube-resign-characterization.test.ts | 30 +++ src/Game/__tests__/game-accessors.test.ts | 85 +++++++ src/Game/__tests__/guards.test.ts | 69 ++++++ src/Game/__tests__/robot.test.ts | 57 +++++ src/Game/__tests__/turnflow.test.ts | 213 ++++++++++++++++++ 6 files changed, 469 insertions(+) create mode 100644 src/Game/__tests__/game-accessors.test.ts create mode 100644 src/Game/__tests__/guards.test.ts create mode 100644 src/Game/__tests__/robot.test.ts create mode 100644 src/Game/__tests__/turnflow.test.ts diff --git a/jest.config.js b/jest.config.js index 189075e..3b488c1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,21 @@ module.exports = { verbose: false, collectCoverage, coverageReporters: ['text', 'lcov'], + // Coverage gate for the decomposed Game modules (nodots/backgammon-core#134). + // Enforced only under COVERAGE=1 (CI). A directory-path key aggregates + // coverage across all files under src/Game/ (rather than per-file), so the + // well-covered modules (shared/guards/lifecycle/undo/cube) balance the + // thinner delegators (executeRobotTurn) and the large turnFlow file. The + // branch floor sits just under the current aggregate and ratchets toward 75 + // as the remaining turnFlow no-move/blocked paths gain coverage. + coverageThreshold: { + './src/Game/': { + statements: 75, + functions: 75, + lines: 75, + branches: 70, + }, + }, testMatch: ['**/?(*.)+(test).ts'], moduleFileExtensions: ['ts', 'js', 'json', 'node'], transform: { diff --git a/src/Game/__tests__/cube-resign-characterization.test.ts b/src/Game/__tests__/cube-resign-characterization.test.ts index 8d1105e..9287e4f 100644 --- a/src/Game/__tests__/cube-resign-characterization.test.ts +++ b/src/Game/__tests__/cube-resign-characterization.test.ts @@ -75,6 +75,36 @@ describe('Game.canAcceptDouble() / acceptDouble() — characterization', () => { 'Cannot accept double' ) }) + + it('acceptDouble at 64 completes the game (maxxed cube)', () => { + const doubled = Game.double(buildRollingGame() as any) + const offering = doubled.cube.offeredBy! + const accepting = doubled.players.find((p) => p.id !== offering.id)! + // Force the offered value to the max; accepting it ends the game. + const at64 = { ...doubled, cube: { ...doubled.cube, value: 64 } } as any + const done = Game.acceptDouble(at64, accepting as any) + expect(done.stateKind).toBe('completed') + expect((done as any).pointsWon).toBe(64) + expect(done.cube.stateKind).toBe('maxxed') + }) +}) + +describe('Game.refuseDouble() — characterization', () => { + it('reverts to a doubled cube at half value when refusing a non-first double', () => { + const doubled = Game.double(buildRollingGame() as any) + const offering = doubled.cube.offeredBy! + const refusing = doubled.players.find((p) => p.id !== offering.id)! + // Cube already at 4 before this offer (a re-double, not the first double). + const at4 = { + ...doubled, + cube: { ...doubled.cube, value: 4, owner: offering }, + } as any + const done = Game.refuseDouble(at4, refusing as any) + expect(done.stateKind).toBe('completed') + // Winner gets the pre-double value: 4 / 2 = 2. + expect((done as any).pointsWon).toBe(2) + expect(done.cube.stateKind).toBe('doubled') + }) }) describe('Game.resign() — characterization', () => { diff --git a/src/Game/__tests__/game-accessors.test.ts b/src/Game/__tests__/game-accessors.test.ts new file mode 100644 index 0000000..ef3a7e7 --- /dev/null +++ b/src/Game/__tests__/game-accessors.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from '@jest/globals' +import { Board } from '../../Board' +import { Game } from '../index' + +// A real game rolled into 'moving' state (board + active/inactive players). +function buildMovingGame() { + const game = Game.createNewGame( + { userId: 'p1', isRobot: false }, + { userId: 'p2', isRobot: false } + ) + const rolledForStart = Game.rollForStart(game) + return Game.roll(rolledForStart) +} + +describe('Game.activePlayer() / inactivePlayer()', () => { + it('returns the active and inactive players by color/state', () => { + const g = buildMovingGame() + const active = Game.activePlayer(g) + const inactive = Game.inactivePlayer(g) + expect(active.color).toBe(g.activeColor) + expect(inactive.color).not.toBe(g.activeColor) + expect(inactive.stateKind).toBe('inactive') + }) + + it('throws when no active player matches', () => { + const g = buildMovingGame() + const broken = { ...g, activeColor: undefined } as any + expect(() => Game.activePlayer(broken)).toThrow('Active player not found') + }) + + it('throws when no inactive player matches', () => { + const g = buildMovingGame() + // Both players share activeColor so none is inactive-for-that-color. + const broken = { + ...g, + players: g.players.map((p) => ({ ...p, color: g.activeColor })), + } as any + expect(() => Game.inactivePlayer(broken)).toThrow('Inactive player not found') + }) +}) + +describe('Game.getPlayersForColor()', () => { + it('returns [active, inactive] for the given color', () => { + const g = buildMovingGame() + const [active, inactive] = Game.getPlayersForColor(g.players, g.activeColor) + expect(active.color).toBe(g.activeColor) + expect(inactive.color).not.toBe(g.activeColor) + }) + + it('throws when a matching player is missing', () => { + const g = buildMovingGame() + const oneColor = g.players.map((p) => ({ ...p, color: 'white' })) as any + expect(() => Game.getPlayersForColor(oneColor, 'white')).toThrow( + 'Players not found' + ) + }) +}) + +describe('Game.findChecker()', () => { + it('returns the checker when it exists on the board', () => { + const g = buildMovingGame() + const anyChecker = Board.getCheckers(g.board)[0] + const found = Game.findChecker(g, anyChecker.id) + expect(found?.id).toBe(anyChecker.id) + }) + + it('returns null when the checker id is not found', () => { + const g = buildMovingGame() + expect(Game.findChecker(g, 'no-such-checker')).toBeNull() + }) +}) + +describe('Game.createNewGame() with rules', () => { + it('merges provided rules onto the base game', () => { + const g = Game.createNewGame( + { userId: 'p1', isRobot: false }, + { userId: 'p2', isRobot: true }, + { rules: { useJacobyRule: true, useBeaverRule: true } } + ) + expect(g.rules?.useJacobyRule).toBe(true) + expect(g.rules?.useBeaverRule).toBe(true) + expect(g.stateKind).toBe('rolling-for-start') + expect(g.players).toHaveLength(2) + }) +}) diff --git a/src/Game/__tests__/guards.test.ts b/src/Game/__tests__/guards.test.ts new file mode 100644 index 0000000..87282d7 --- /dev/null +++ b/src/Game/__tests__/guards.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from '@jest/globals' +import { BackgammonGame } from '@nodots/backgammon-types' +import { Game } from '../index' + +// Minimal game shape for exercising the pure state predicates. +function gameWith( + stateKind: string, + extra: Partial = {} +): BackgammonGame { + return { + stateKind, + activeColor: 'white', + players: [ + { id: 'p1', color: 'white' }, + { id: 'p2', color: 'black' }, + ], + ...extra, + // Predicate-only fixture; the full BackgammonGame shape is not needed. + } as any +} + +describe('Game.canRoll()', () => { + it.each(['rolled-for-start', 'rolling', 'doubled'])( + 'is true in %s state', + (kind) => { + expect(Game.canRoll(gameWith(kind))).toBe(true) + } + ) + + it.each(['rolling-for-start', 'moving', 'moved', 'completed'])( + 'is false in %s state', + (kind) => { + expect(Game.canRoll(gameWith(kind))).toBe(false) + } + ) +}) + +describe('Game.canRollForStart()', () => { + it('is true only in rolling-for-start state', () => { + expect(Game.canRollForStart(gameWith('rolling-for-start'))).toBe(true) + expect(Game.canRollForStart(gameWith('rolling'))).toBe(false) + }) +}) + +describe('Game.canGetPossibleMoves()', () => { + it('is true only in moving state', () => { + expect(Game.canGetPossibleMoves(gameWith('moving'))).toBe(true) + expect(Game.canGetPossibleMoves(gameWith('rolling'))).toBe(false) + }) +}) + +describe('Game.canPlayerRoll()', () => { + it('is false when the game cannot roll at all', () => { + expect(Game.canPlayerRoll(gameWith('moving'), 'p1')).toBe(false) + }) + + it('is true for the active player in a rollable state', () => { + expect(Game.canPlayerRoll(gameWith('rolling'), 'p1')).toBe(true) + }) + + it('is false for the non-active player', () => { + expect(Game.canPlayerRoll(gameWith('rolling'), 'p2')).toBe(false) + }) + + it('is true regardless of id when there is no activeColor', () => { + const g = gameWith('rolling', { activeColor: undefined as any }) + expect(Game.canPlayerRoll(g, 'anyone')).toBe(true) + }) +}) diff --git a/src/Game/__tests__/robot.test.ts b/src/Game/__tests__/robot.test.ts new file mode 100644 index 0000000..fb970e6 --- /dev/null +++ b/src/Game/__tests__/robot.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from '@jest/globals' +import { BackgammonGame, BackgammonGameMoved } from '@nodots/backgammon-types' +import { Game } from '../index' + +// Build a real game rolled into play, then force it to 'moved' so the +// turn-confirmation path can run. isRobot controls both players. +function buildMovedGame(isRobot: boolean): BackgammonGameMoved { + const game = Game.createNewGame( + { userId: 'p1', isRobot }, + { userId: 'p2', isRobot } + ) + const rolledForStart = Game.rollForStart(game) + const rolled = Game.roll(rolledForStart) + // Forcing to 'moved' for the confirmation-path fixture. + return { + ...rolled, + stateKind: 'moved', + activePlayer: { ...rolled.activePlayer, stateKind: 'moved' }, + } as any +} + +describe('Game.handleRobotMovedState()', () => { + it('confirms the turn when in moved state with a robot active player', () => { + const moved = buildMovedGame(true) + const result = Game.handleRobotMovedState(moved) + expect(result.stateKind).toBe('rolling') + }) + + it('returns the game unchanged when the active player is human', () => { + const moved = buildMovedGame(false) + const result = Game.handleRobotMovedState(moved) + expect(result).toBe(moved) + expect(result.stateKind).toBe('moved') + }) + + it('returns the game unchanged when not in moved state', () => { + const notMoved = { stateKind: 'rolling' } as unknown as BackgammonGame + expect(Game.handleRobotMovedState(notMoved)).toBe(notMoved) + }) +}) + +describe('Game.confirmTurnWithRobotAutomation()', () => { + it('confirms the turn and returns a rolling game (both robots)', async () => { + const moved = buildMovedGame(true) + const result = await Game.confirmTurnWithRobotAutomation(moved) + expect(result.stateKind).toBe('rolling') + // Next player is a robot; automation is external so the game is returned as-is. + expect(result.activePlayer?.isRobot).toBe(true) + }) + + it('confirms the turn when the next player is human', async () => { + const moved = buildMovedGame(false) + const result = await Game.confirmTurnWithRobotAutomation(moved) + expect(result.stateKind).toBe('rolling') + expect(result.activePlayer?.isRobot).toBe(false) + }) +}) diff --git a/src/Game/__tests__/turnflow.test.ts b/src/Game/__tests__/turnflow.test.ts new file mode 100644 index 0000000..343a6dc --- /dev/null +++ b/src/Game/__tests__/turnflow.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from '@jest/globals' +import type { + BackgammonCheckerContainerImport, + BackgammonGameMoving, + BackgammonPlayerInactive, + BackgammonPlayerRollingForStart, +} from '@nodots/backgammon-types' +import { Board } from '../../Board' +import { Cube } from '../../Cube' +import { Dice } from '../../Dice' +import { Play } from '../../Play' +import { Player } from '../../Player' +import { Game } from '../index' + +const pointCC = ( + posCC: number, + qty: number, + color: 'black' | 'white' +): BackgammonCheckerContainerImport => ({ + // Branded position literals; the arithmetic result is a plain number. + position: { clockwise: (25 - posCC) as any, counterclockwise: posCC as any }, + checkers: { qty, color }, +}) + +// Moving game: a single black checker at CC 24, open board, roll [1,2]. +// Both dice are legal, so the play has two ready moves and an empty undo stack. +function buildMovingGame(): BackgammonGameMoving { + const board = Board.buildBoard([pointCC(24, 1, 'black')]) + const blackRolling = Player.initialize( + 'black', + 'counterclockwise', + 'rolling', + false + ) as any + const blackRolled = Player.roll(blackRolling) + blackRolled.dice.currentRoll = [1, 2] + const blackMoving = Player.toMoving(blackRolled) + const whiteInactive = Player.initialize( + 'white', + 'clockwise', + 'inactive', + false + ) as BackgammonPlayerInactive + const play = Play.initialize(board, blackMoving) + return Game.initialize( + [blackMoving, whiteInactive] as any, + 'turnflow-game', + 'moving', + board, + Cube.initialize(), + play, + 'black', + blackMoving, + whiteInactive + ) as BackgammonGameMoving +} + +function ccOriginCheckerId(game: BackgammonGameMoving): string { + const originPoint = Board.getPoints(game.board).find( + (p) => p.position.counterclockwise === 24 + )! + return originPoint.checkers.find((c) => c.color === 'black')!.id +} + +// A game forced into 'rolling' state, ready for Game.roll's rolling branch. +// The active-color player in the players array must also be 'rolling' with +// rolling dice, since Game.roll reads it via getPlayersForColor. +function buildRollingGame() { + const game = Game.createNewGame( + { userId: 'p1', isRobot: false }, + { userId: 'p2', isRobot: false } + ) + const rfs = Game.rollForStart(game) + const activeColor = rfs.activeColor + const activeRolling = { + ...rfs.activePlayer, + stateKind: 'rolling', + dice: Dice.initialize(rfs.activePlayer.color, 'rolling'), + } + const inactive = { ...rfs.inactivePlayer, stateKind: 'inactive' } + const players = rfs.players.map((p) => + p.color === activeColor ? activeRolling : inactive + ) + return { + ...rfs, + stateKind: 'rolling', + players, + activePlayer: activeRolling, + inactivePlayer: inactive, + } as any +} + +describe('Game.roll()', () => { + it('rolls from rolled-for-start using the roll-for-start values', () => { + const game = Game.createNewGame( + { userId: 'p1', isRobot: false }, + { userId: 'p2', isRobot: false } + ) + const rolledForStart = Game.rollForStart(game) + const rolled = Game.roll(rolledForStart) + expect(['moving', 'moved']).toContain(rolled.stateKind) + expect(rolled.activePlayer.dice.currentRoll).toBeDefined() + }) + + it('rolls from rolling state (generates new dice)', () => { + const rolled = Game.roll(buildRollingGame()) + expect(['moving', 'moved']).toContain(rolled.stateKind) + }) + + it('rolls from doubled state after a double', () => { + const doubled = Game.double(buildRollingGame()) + const rolled = Game.roll(doubled as any) + expect(['moving', 'moved']).toContain(rolled.stateKind) + }) + + it('throws on an unexpected state', () => { + expect(() => Game.roll({ stateKind: 'moving' } as any)).toThrow() + }) +}) + +describe('Game.switchDice()', () => { + it('swaps the dice order and regenerates moves', () => { + const game = buildMovingGame() + const before = game.activePlayer.dice.currentRoll + const switched = Game.switchDice(game) + expect(switched.activePlayer.dice.currentRoll).toEqual([ + before![1], + before![0], + ]) + }) + + it('throws when not in moving state', () => { + const game = buildMovingGame() + expect(() => Game.switchDice({ ...game, stateKind: 'rolling' } as any)).toThrow( + 'Cannot switch dice' + ) + }) + + it('throws when moves are not all undone', () => { + const game = buildMovingGame() + // Mark a move completed so not-all-undone. + ;(game.activePlay as any).moves[0].stateKind = 'completed' + expect(() => Game.switchDice(game)).toThrow('all moves are undone') + }) +}) + +describe('Game.executeAndRecalculate()', () => { + it('executes a move from an origin container', () => { + const game = buildMovingGame() + const originId = Board.getPoints(game.board).find( + (p) => p.position.counterclockwise === 24 + )!.id + const after = Game.executeAndRecalculate(game, originId) + expect(after).toBeDefined() + expect(['moving', 'moved', 'completed']).toContain(after.stateKind) + }) + + it('throws when no checker exists in the origin', () => { + const game = buildMovingGame() + const emptyOrigin = Board.getPoints(game.board).find( + (p) => p.checkers.length === 0 + )!.id + expect(() => Game.executeAndRecalculate(game, emptyOrigin)).toThrow( + 'checker found in container' + ) + }) +}) + +describe('Game.checkAndCompleteTurn()', () => { + it('returns the game unchanged when moves are incomplete', () => { + const game = buildMovingGame() + const result = Game.checkAndCompleteTurn(game) + // Both dice still playable from CC24, so the turn is not complete. + expect(result.stateKind).toBe('moving') + }) + + it('returns the game unchanged for an invalid game structure', () => { + const broken = { stateKind: 'moving' } as any + expect(Game.checkAndCompleteTurn(broken)).toBe(broken) + }) +}) + +describe('Game.toMoved()', () => { + it('throws when not in moving state', () => { + const game = buildMovingGame() + expect(() => Game.toMoved({ ...game, stateKind: 'rolling' } as any)).toThrow( + "Must be in 'moving' state" + ) + }) + + it('throws when not all moves are completed', () => { + const game = buildMovingGame() + expect(() => Game.toMoved(game)).toThrow('not all moves are completed') + }) + + it('transitions to moved when all moves are completed', () => { + const game = buildMovingGame() + ;(game.activePlay as any).moves.forEach((m: any) => { + m.stateKind = 'completed' + }) + const moved = Game.toMoved(game) + expect(moved.stateKind).toBe('moved') + }) +}) + +describe('Game.moveAndFinalize()', () => { + it('executes a move and returns a valid state', () => { + const game = buildMovingGame() + const after = Game.moveAndFinalize(game, ccOriginCheckerId(game)) + expect(['moving', 'moved', 'completed']).toContain(after.stateKind) + }) +}) +