@idealic/poker-engine
Version:
Poker game engine and hand evaluator
519 lines (434 loc) • 15.9 kB
text/typescript
import { describe, expect, it } from 'vitest';
import {
getActionAmount,
getActionCards,
getActionMessage,
getActionPlayerIndex,
getActionTimestamp,
getActionType,
} from '../../../game/position';
import * as Poker from '../../../index';
import { BASE_HAND } from './fixtures/baseHand';
/**
* Edge Cases Tests for Hand API
*
* Purpose: Test edge cases and boundary conditions for Hand methods
* Note: We don't test invalid Hand field types - that's TypeScript's job
* We do test broken action formats since they're runtime strings
*
* Uses BASE_HAND as reference
*/
describe('Hand Edge Cases', () => {
describe('Method Edge Cases', () => {
describe('getPlayerId edge cases', () => {
it('should handle negative indices', () => {
const hand = Poker.Hand({
...BASE_HAND,
_venueIds: ['id1', 'id2', 'id3'],
});
expect(Poker.Hand.getPlayerId(hand, -1)).toBe(null);
expect(Poker.Hand.getPlayerId(hand, -100)).toBe(null);
});
it('should handle very large indices', () => {
const hand = Poker.Hand({
...BASE_HAND,
_venueIds: ['id1', 'id2', 'id3'],
});
expect(Poker.Hand.getPlayerId(hand, 1000)).toBe(null);
expect(Poker.Hand.getPlayerId(hand, 100_000)).toBe(null);
});
it('should handle empty _venueIds array', () => {
const hand = Poker.Hand({
...BASE_HAND,
_venueIds: [],
});
expect(Poker.Hand.getPlayerId(hand, 0)).toBe(null);
});
it('should handle sparse _venueIds array', () => {
const hand = Poker.Hand({
...BASE_HAND,
_venueIds: [undefined, 'id2', undefined] as any,
});
expect(Poker.Hand.getPlayerId(hand, 0)).toBe(null);
expect(Poker.Hand.getPlayerId(hand, 1)).toBe('id2');
expect(Poker.Hand.getPlayerId(hand, 2)).toBe(null);
});
});
describe('getPlayerIndex edge cases', () => {
it('should handle empty string name', () => {
const hand = Poker.Hand(BASE_HAND);
expect(Poker.Hand.getPlayerIndex(hand, '')).toBe(-1);
});
it('should handle special characters in name', () => {
const hand = Poker.Hand({
...BASE_HAND,
players: ['Player#1', 'Player@2', 'Player$3'],
});
expect(Poker.Hand.getPlayerIndex(hand, 'Player#1')).toBe(0);
expect(Poker.Hand.getPlayerIndex(hand, 'Player@2')).toBe(1);
expect(Poker.Hand.getPlayerIndex(hand, 'Player$3')).toBe(2);
});
it('should handle very long names', () => {
const longName = 'A'.repeat(1000);
const hand = Poker.Hand({
...BASE_HAND,
players: [longName, 'Bob', 'Charlie'],
});
expect(Poker.Hand.getPlayerIndex(hand, longName)).toBe(0);
});
});
describe('getTimeLeft edge cases', () => {
it('should handle future timestamps', () => {
const futureTime = Date.now() + 10000;
const hand = Poker.Hand({
...BASE_HAND,
timeLimit: 30, // 30 second time limit
actions: [`p1 f #${futureTime}`],
});
const remaining = Poker.Hand.getTimeLeft(hand);
// Future timestamp means negative elapsed time, so MORE remaining time than the limit
expect(remaining).toBeGreaterThan(30000);
expect(remaining).toBeLessThanOrEqual(40000); // Should be around 30000 + 10000
});
it('should handle invalid timestamps in actions', () => {
const hand = Poker.Hand({
...BASE_HAND,
timeLimit: 30, // 30 second time limit
actions: ['p1 f #invalid', 'p2 cc #abc', 'p3 cbr 100 #'],
});
// Should return full time limit if no valid timestamp found
expect(Poker.Hand.getTimeLeft(hand)).toBe(30000);
});
it('should handle very old timestamps', () => {
const hand = Poker.Hand({
...BASE_HAND,
timeLimit: 30, // 30 second time limit
actions: [`p1 f #0000000000001`], // Valid 13-digit timestamp (1ms after epoch)
});
const remaining = Poker.Hand.getTimeLeft(hand);
// Very old timestamp means time expired long ago
expect(remaining).toBe(0);
});
it('should handle mixed timestamped and non-timestamped actions', () => {
const now = Date.now();
const hand = Poker.Hand({
...BASE_HAND,
timeLimit: 30, // 30 second time limit
actions: [
'p1 f', // No timestamp
`p2 cc #${now - 5000}`, // 5 seconds ago
'p3 cbr 100', // No timestamp
],
});
const remaining = Poker.Hand.getTimeLeft(hand);
// Should use the last timestamped action (5 seconds ago)
// Remaining time should be around 25000ms (30000 - 5000)
expect(remaining).toBeGreaterThanOrEqual(24900);
expect(remaining).toBeLessThanOrEqual(25100);
});
});
describe('merge edge cases', () => {
it('should handle merging with empty hands', () => {
const hand = Poker.Hand(BASE_HAND);
const emptyHand = Poker.Hand({
...BASE_HAND,
actions: [],
});
const merged1 = Poker.Hand.merge(hand, emptyHand);
expect(merged1.actions).toEqual(BASE_HAND.actions);
});
it('should handle merging hands with conflicting metadata', () => {
// Different venues - can't merge
const hand1 = Poker.Hand({
...BASE_HAND,
venue: 'Venue1',
});
const hand2 = Poker.Hand({
...BASE_HAND,
venue: 'Venue2',
});
const merged = Poker.Hand.merge(hand1, hand2);
// Should return first hand unchanged when venues differ
expect(merged).toEqual(hand1);
// Different currencies - can't merge
const hand3 = Poker.Hand({
...BASE_HAND,
currency: 'USD',
});
const hand4 = Poker.Hand({
...BASE_HAND,
currency: 'EUR',
});
const merged2 = Poker.Hand.merge(hand3, hand4);
// Should return first hand unchanged when currencies differ
expect(merged2).toEqual(hand3);
// Author field is always removed from merged hands
const hand5 = Poker.Hand({
...BASE_HAND,
author: 'Alice',
});
const hand6 = Poker.Hand({
...BASE_HAND,
author: 'Bob',
});
const merged3 = Poker.Hand.merge(hand5, hand6);
// Should merge successfully and always remove author field
expect(merged3.author).toBeUndefined();
});
it('should reject merging hands with different variants', () => {
const ntHand = Poker.Hand(BASE_HAND, {
variant: 'NT',
minBet: 20,
});
const ftHand = Poker.Hand(BASE_HAND, {
variant: 'FT',
smallBet: 10,
bigBet: 20,
blindsOrStraddles: [0, 5, 10], // FT: BB=smallBet=10, SB=5
});
const merged = Poker.Hand.merge(ntHand, ftHand);
expect(merged).toEqual(ntHand);
});
it('should reject merging hands with different table/game IDs', () => {
const hand1 = Poker.Hand({
...BASE_HAND,
table: 'table-123',
});
const hand2 = Poker.Hand({
...BASE_HAND,
table: 'table-456',
});
const merged = Poker.Hand.merge(hand1, hand2);
expect(merged).toEqual(hand1);
// Different game IDs
const hand3 = Poker.Hand({
...BASE_HAND,
hand: 123,
});
const hand4 = Poker.Hand({
...BASE_HAND,
hand: 321,
});
const merged2 = Poker.Hand.merge(hand3, hand4);
expect(merged2).toEqual(hand3);
});
it('should reject merging hands with different seeds', () => {
const hand1 = Poker.Hand({
...BASE_HAND,
seed: 12345,
});
const hand2 = Poker.Hand({
...BASE_HAND,
seed: 67890,
});
const merged = Poker.Hand.merge(hand1, hand2);
expect(merged).toEqual(hand1);
});
it('should reject merging hands with different player arrays', () => {
// Different players
const hand1 = Poker.Hand({
...BASE_HAND,
players: ['Alice', 'Bob', 'Charlie'],
});
const hand2 = Poker.Hand({
...BASE_HAND,
players: ['Alice', 'Bob', 'David'],
});
const merged = Poker.Hand.merge(hand1, hand2);
expect(merged).toEqual(hand1);
// Different starting stacks
const hand3 = Poker.Hand({
...BASE_HAND,
startingStacks: [1000, 1000, 1000],
});
const hand4 = Poker.Hand({
...BASE_HAND,
startingStacks: [1000, 1500, 1000],
});
const merged2 = Poker.Hand.merge(hand3, hand4);
expect(merged2).toEqual(hand3);
// Different blinds
const hand5 = Poker.Hand({
...BASE_HAND,
minBet: 20,
blindsOrStraddles: [10, 20, 0],
});
const hand6 = Poker.Hand({
...BASE_HAND,
minBet: 50,
blindsOrStraddles: [25, 50, 0],
});
const merged3 = Poker.Hand.merge(hand5, hand6);
expect(merged3).toEqual(hand5);
});
it('should reject merging hands with different betting limits', () => {
// Different minBet for NT variant
const hand1 = Poker.Hand({
...BASE_HAND,
variant: 'NT',
minBet: 20,
} as Poker.Hand);
const hand2 = Poker.Hand({
...BASE_HAND,
variant: 'NT',
minBet: 50,
blindsOrStraddles: [0, 25, 50], // Match minBet: 50
} as Poker.Hand);
const merged = Poker.Hand.merge(hand1, hand2);
expect(merged).toEqual(hand1);
// Different betting structure for FT variant
const hand3 = Poker.Hand({
variant: 'FT',
players: ['Alice', 'Bob'],
startingStacks: [1000, 1000],
blindsOrStraddles: [5, 10], // FT: BB=smallBet=10, SB=5
smallBet: 10,
bigBet: 20,
actions: [],
antes: [0, 0],
});
const hand4 = Poker.Hand({
variant: 'FT',
players: ['Alice', 'Bob'],
startingStacks: [1000, 1000],
blindsOrStraddles: [10, 20], // FT: BB=smallBet=20, SB=10
smallBet: 20,
bigBet: 40,
actions: [],
antes: [0, 0],
});
const merged2 = Poker.Hand.merge(hand3, hand4);
expect(merged2).toEqual(hand3);
});
it('should handle merging with undefined fields gracefully', () => {
const hand1 = Poker.Hand({
...BASE_HAND,
venue: 'Venue1',
});
const hand2 = Poker.Hand({
...BASE_HAND,
// No venue field
});
const merged = Poker.Hand.merge(hand1, hand2);
// Should succeed when only one has the field
expect(merged.venue).toBe('Venue1');
expect(merged.actions).toEqual(BASE_HAND.actions);
});
it('should prevent metadata overwrite', () => {
const hand1 = Poker.Hand({
...BASE_HAND,
time: '2024-01-01T10:00:00Z',
timeLimit: 30,
rake: 5,
});
const hand2 = Poker.Hand({
...BASE_HAND,
timeLimit: 60,
rake: 10,
rakePercentage: 0.05,
winnings: [100, 0, 0],
time: '2024-01-01T10:01:00Z',
});
const merged = Poker.Hand.merge(hand1, hand2);
// Should update all metadata from hand2
expect(merged.time).toBe('2024-01-01T10:00:00Z');
expect(merged.timeLimit).toBe(30);
expect(merged.rake).toBe(5);
expect(merged.rakePercentage).toBeUndefined();
expect(merged.winnings).toBeUndefined();
});
});
describe('isEqual edge cases', () => {
it('should detect subtle differences', () => {
const hand1 = Poker.Hand(BASE_HAND);
const hand2 = Poker.Hand({
...BASE_HAND,
seed: 12346, // Different by 1
});
expect(Poker.Hand.isEqual(hand1, hand2)).toBe(false);
});
});
describe('personalize edge cases', () => {
it('should handle invalid player identifier', () => {
const hand = Poker.Hand(BASE_HAND);
const personalized = Poker.Hand.personalize(hand, 'NonExistentPlayer');
// Should return hand with author set to invalid player (observer)
expect(personalized.author).toBe('NonExistentPlayer');
// Observer can't see any hole cards
const dealActions = personalized.actions.filter(a => getActionType(a) === 'dh');
dealActions.forEach(action => {
const cards = getActionCards(action);
expect(cards).toEqual(['??', '??']); // No hole cards visible to observer
});
});
it('should handle negative player index', () => {
const hand = Poker.Hand(BASE_HAND);
const personalized = Poker.Hand.personalize(hand, -1);
// Should set empty author for invalid index
expect(personalized.author).toBe('');
// Hole cards should be hidden
const dealActions = personalized.actions.filter(a => getActionType(a) === 'dh');
dealActions.forEach(action => {
const cards = getActionCards(action);
expect(cards).toEqual(['??', '??']); // No hole cards visible
});
});
});
});
describe('Action Extraction Utilities', () => {
it('should properly extract all components from valid actions', () => {
const testActions = [
{
action: 'd dh p1 AsKs #1700000000000',
type: 'dh',
player: 0,
cards: ['As', 'Ks'],
timestamp: 1700000000000,
},
{
action: 'p2 cbr 100 #1700000001000',
type: 'cbr',
player: 1,
amount: 100,
timestamp: 1700000001000,
},
{
action: 'p3 m Hello world! #1700000002000',
type: 'm',
player: 2,
message: 'Hello world!',
timestamp: 1700000002000,
},
{
action: 'd db AhKhQd #1700000003000',
type: 'db',
cards: ['Ah', 'Kh', 'Qd'],
timestamp: 1700000003000,
},
];
testActions.forEach(test => {
if (test.type !== undefined) expect(getActionType(test.action)).toBe(test.type);
if (test.player !== undefined) expect(getActionPlayerIndex(test.action)).toBe(test.player);
if (test.cards !== undefined) expect(getActionCards(test.action)).toEqual(test.cards);
if (test.amount !== undefined) expect(getActionAmount(test.action)).toBe(test.amount);
if (test.message !== undefined) expect(getActionMessage(test.action)).toBe(test.message);
if (test.timestamp !== undefined)
expect(getActionTimestamp(test.action)).toBe(test.timestamp);
});
});
it('should handle edge cases in action extraction', () => {
// Actions at boundaries
expect(getActionPlayerIndex('p0 f')).toBe(-1); // p0 would be -1 after conversion
expect(getActionPlayerIndex('p10 f')).toBe(9); // p10 = index 9
expect(getActionPlayerIndex('p999 f')).toBe(998); // p999 = index 998
// Actions with no timestamp
const actionNoTimestamp = 'p1 f';
const beforeTime = Date.now();
const timestamp = getActionTimestamp(actionNoTimestamp);
const afterTime = Date.now();
// Should return current time as default
expect(timestamp).toBeGreaterThanOrEqual(beforeTime);
expect(timestamp).toBeLessThanOrEqual(afterTime);
});
});
});