Files
JoshHeaps.Net/tests/holdem/state.test.js
T
jheapsandClaude Opus 5 c1a484b97a Pay down the Hold'em dealer's tech debt and give it a test suite
Every module in the dealer ends with a `module.exports` guard, so they were all
written to be testable, and none of them were ever tested. The correctness fixes
that went in recently were checked by hand in a browser console that has since
been closed. This puts a real safety net under them and clears out the
duplication that made those bugs possible in the first place.

Tests (58, ~300ms, no dependencies)

Node's built-in runner, no package.json and no install step: the modules are
plain script-tag files, so `load.js` just requires them and puts them on
globalThis under the names they use to find each other in the page.

The randomised suite is the part that matters. Every bug found in this tool so
far has been shape-dependent — blinds posted to empty chairs, the button
skipping players, heads-up acting out of order — and none of them show up on the
full six-handed table anyone would write a fixed example for. It deals 1000
hands per property across random seat counts, empty chairs, stack depths and
dealer input, checking that chips are never created or destroyed, that the pot
breakdown plus uncalled returns equals the money in, that paying out settles to
the cent, and that the button and blinds always land on real players. The
generator is seeded so a failure reproduces instead of flaking.

One implementation each

- `Math.round(x * 100) / 100` was written out 16 times across 6 files. It's
  `Money.round` / `toCents` / `fromCents` now, in the module both pages already
  load first.
- The chip epsilon was declared twice with the same value under two names
  (`PotCalculator.EPSILON`, `Betting.EPSILON_CHIPS`). Both now point at
  `Money.EPSILON`.
- `Betting.seatAt` and `HoldemState.nextSeatIndex` were the same modular
  arithmetic written twice. Walking the seats is exactly where the button, the
  blinds and the action drifted apart from each other before, so there is one
  of it now.
- The undo snapshot/restore dance appeared three times in main.js, each one
  re-deleting every key on `state` in place. It's `snapshotState` /
  `restoreState` once, with the reason it can't just reassign written down.

A bug the tests found

`layers()` and `uncalledReturns()` both decide what money was never contested,
separately, and disagreed. `uncalledReturns` measured each player against the
biggest live contribution *including their own*, which makes the biggest bet its
own yardstick — so the one case it exists for came back empty, and `layers()`
announced the overbet as a side pot only the bettor could win.

With Ann shoving 200 into Ben's 60, the dealer was told "main pot 4, side pot 3,
side pot 116, side pot 140" — four pots, one of them fictional. It now reads
"main pot 123, uncalled 140 back to Ann", which is what a dealer says out loud.
Layers are derived from contributions with the uncalled money already removed,
so the two can't disagree again. A band every payer folded out of is now merged
into the pot below rather than dropped, which was quietly shrinking the
announced pot in another edge case.

Validated with Playwright against a real hand on both surfaces — the table's
live pot readout and Quick Split's all-in mode — plus undo, with no console
errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:00:36 -06:00

148 lines
6.5 KiB
JavaScript

const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { HoldemState, makeTable } = require("./load");
describe("HoldemState.advanceButton", () => {
test("the button moves to the next player, not the next empty chair", () => {
// Four players spread around a ten-seat table: 0, 3, 4, 8.
const state = makeTable({ seatCount: 10, seated: [0, 3, 4, 8], dealerSeatIndex: 0 });
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 3, "skips the three empty chairs between");
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 4);
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 8);
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 0, "wraps back round to the first player");
});
test("every player gets the button once per orbit", () => {
const seated = [1, 2, 6, 7, 9];
const state = makeTable({ seatCount: 12, seated, dealerSeatIndex: 1 });
const visited = [];
for (let i = 0; i < seated.length; i++) {
visited.push(state.dealerSeatIndex);
HoldemState.advanceButton(state);
}
assert.deepEqual(visited.slice().sort((a, b) => a - b), seated);
});
test("it still moves when nobody is dealt in, rather than looking stuck", () => {
const state = makeTable({ seatCount: 6, seated: [], dealerSeatIndex: 2 });
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 3);
});
test("counterclockwise play runs the button the other way", () => {
const state = makeTable({ seatCount: 6, seated: [0, 2, 4], dealerSeatIndex: 4 });
state.direction = "counterclockwise";
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 2);
});
});
describe("HoldemState.getBlindSeats", () => {
test("blinds follow the players, not the chairs left of the button", () => {
// Seats 1 and 2 are empty, so the blinds belong on 3 and 7.
const state = makeTable({ seatCount: 9, seated: [0, 3, 7], dealerSeatIndex: 0 });
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, false);
assert.equal(sbIndex, 3);
assert.equal(bbIndex, 7);
});
test("heads-up, the button posts the small blind", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, true);
assert.equal(sbIndex, 2, "the button is the small blind heads-up");
assert.equal(bbIndex, 5);
});
test("heads-up with the button on an empty chair still posts both blinds", () => {
const state = makeTable({ seatCount: 6, seated: [1, 4], dealerSeatIndex: 0 });
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, true);
assert.equal(sbIndex, 1);
assert.equal(bbIndex, 4);
assert.notEqual(sbIndex, bbIndex, "two blinds never land on one player");
});
test("fewer than two players means no blinds to post", () => {
const state = makeTable({ seatCount: 6, seated: [3], dealerSeatIndex: 0 });
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.equal(sbIndex, null);
assert.equal(bbIndex, null);
});
});
describe("HoldemState.sessionStandings", () => {
test("a player's cash position counts chips in front plus chips in the pot", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 0 });
const [a] = state.seats;
a.buyIns = 100;
a.stack = 60;
a.amountOverride = 25; // banked into the hand in progress
a.committed = 15; // still in front of them on this street
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 0 }]);
const standings = HoldemState.sessionStandings(state);
const row = standings.find(s => s.id === a.id);
// 60 in front + 40 in the pot - 100 bought in = level.
assert.equal(row.cashPosition, 0, "money in the pot is still theirs until it's won");
assert.equal(row.boughtIn, 100);
assert.equal(row.chipsNow, 60);
});
test("hand results accumulate across the session", () => {
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
const [a, b] = state.seats;
a.amountOverride = 10;
b.amountOverride = 10;
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 20 }]);
a.amountOverride = 5;
b.amountOverride = 5;
HoldemState.recordHand(state, [{ id: b.id, label: "P2", amount: 10 }]);
const standings = HoldemState.sessionStandings(state);
assert.equal(standings.find(s => s.id === a.id).net, 5);
assert.equal(standings.find(s => s.id === b.id).net, -5);
assert.equal(state.handNumber, 3, "the counter moved on twice");
});
test("the history is capped so a long session can still be saved", () => {
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
for (let i = 0; i < HoldemState.MAX_HISTORY_ENTRIES + 25; i++) {
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 1 }]);
}
assert.equal(state.handHistory.length, HoldemState.MAX_HISTORY_ENTRIES);
assert.equal(state.handHistory[0].handNumber, HoldemState.MAX_HISTORY_ENTRIES + 25, "newest first");
});
});
describe("HoldemState.resizeSeats", () => {
test("the table is clamped to a size a dealer could actually run", () => {
const state = makeTable({ seatCount: 6 });
HoldemState.resizeSeats(state, 100);
assert.equal(state.seatCount, 24);
HoldemState.resizeSeats(state, 1);
assert.equal(state.seatCount, 2);
});
test("an unreadable seat count falls back to a standard table", () => {
// Empty or non-numeric input (and zero, which is equally meaningless)
// lands on the default rather than a two-seat table.
const state = makeTable({ seatCount: 6 });
HoldemState.resizeSeats(state, NaN);
assert.equal(state.seatCount, HoldemState.DEFAULT_SEAT_COUNT);
});
test("shrinking past the button brings it back onto the table", () => {
const state = makeTable({ seatCount: 9, dealerSeatIndex: 8 });
HoldemState.resizeSeats(state, 4);
assert.ok(state.dealerSeatIndex < 4);
});
});