Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
30 changes: 30 additions & 0 deletions src/Game/__tests__/cube-resign-characterization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
85 changes: 85 additions & 0 deletions src/Game/__tests__/game-accessors.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
69 changes: 69 additions & 0 deletions src/Game/__tests__/guards.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
): 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)
})
})
57 changes: 57 additions & 0 deletions src/Game/__tests__/robot.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading