Files
JoshHeaps.Net/tests/holdem
jheapsandClaude Opus 5 7bd205a91a Line the bar up with the table, and stop the button squeezing a name
Two things that were wrong at every desktop width, both of them the same shape: a
number that was right about one thing being used for another.

The action bar was centred on the window. The table is centred on its own column, and
the sidebar takes 320px out of the right of that — so the two things a dealer is
actually looking at sat 172px apart, at 960px wide and at 1600px and everywhere in
between, because the offset is half the sidebar and the sidebar is a constant. The bar
also finished 80px inside the sidebar's column at 1280, painting over whichever panel
happened to be at that height. It leans away from the sidebar now, by the sidebar's own
width, which comes out exact above and below the 1400px cap — measured on the running
page at four widths, 0px off at all of them. No 100vw in it, which is the point: the
bar's own centring comment warns that on a page with a scrollbar 100vw is wider than
the viewport, and the offsets are exact without it.

The dealer button's chip sits in the puck's top-right corner and the name kept out from
under it with `padding-right: 22px`. That takes the chip's width off *every* line of the
name, and the chip is on one of them. Measured at 1280x900: the name box is 78px,
"Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 and
broke as "Christop / her" — mid-word, no hyphen. The button moves every hand, so that is
a player whose name reads properly for four hands and comes apart on the fifth.

A float is what indents only the lines it sits beside. Line one gets 56px and the rest
get 78, so a name too long to fit beside the chip drops below it whole instead of being
broken to fit next to something that isn't there.

Not on a crowded ring, though, and finding that out cost a sweep. A tight puck gives the
name one line on purpose, and one line has nowhere to move to: with two lines granted
the name dropped onto a second line that the chip's lower half was still sitting on, and
the audit called it at every viewport in the list. So the float is for the ring with room
for two lines and the padding for the one without — two rules, matching two regimes the
puck already had.

The sweep also learned two things from this:

- Text is measured where it was painted, not where its box is. The seat-label check
  modelled "text kept out of somewhere" as padding, which is one way of doing it and not
  the only one; a float does it per line, and the check called every seat in the sweep an
  offender while the text sat exactly where it was meant to. Range rects ask the question
  directly, so padding, floats, centring, wrapping and a short label in a wide box all
  come out right without any of them being modelled.

- Offenders say how much is covered, not that something is. "Is under a badge" reads the
  same for a stack a dealer cannot read and for a badge's rounded corner grazing the last
  digit. Probing the whole line and counting turns the residual this sweep has carried for
  three rounds into "1/7 under SB" — one probe of seven, one configuration, thumb only.

294 audits, 4 failing configurations, 5 offenders: twelve players on a 360px phone with
the raise pad open (3/7 and 2/7, pucks genuinely overlapping — the search reports it
rather than papering over it) and the 1/7 hairline above. 770 unit tests.

Fault-injected, all restored and re-verified: writing the sidebar's width out again
instead of the token, growing the float to cover the chip's whole height, and taking the
padding off the crowded ring are each caught by a test; and with both the float and the
padding removed the rewritten label check still reports the name 6px under the chip,
which is the fault it exists for.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 18:17:16 -06:00
..
2026-09-01 12:34:53 -06:00
2026-09-04 11:18:33 -06:00
2026-09-01 12:52:56 -06:00

Hold'em dealer tests

Unit tests for the money logic behind /Casino/TexasHoldem — the betting engine, the side-pot calculator, the split calculator and the table state.

node --test tests/holdem/*.test.js

No install step, no package.json, no dependencies. The browser modules are plain script-tag files that each end with a module.exports guard, so Node can require them as-is; load.js puts them on globalThis under the same names they use to find each other in the page, in the load order from TexasHoldem.cshtml.

What's covered

File Covers
potCalculator.test.js Main and side pots, dead money, uncalled bets, odd-cent distribution
betting.test.js Blinds, street completion, raises reopening the action, all-in run-outs
state.test.js Button rotation, which seats post blinds, session standings
splitCalculator.test.js Chopped pots, odd chips, unmakeable denominations
handRank.test.js What beats what: categories, the wheel, kickers, best five of seven
readingTheShowdown.test.js The board, the picker that fills it, and who wins a side pot
theRail.test.js A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table
invariants.test.js 1000 randomised hands per property (see below)

The layout audit

The unit tests above cover the money. uiAudit.js covers the thing you can only see in a browser: whether a dealer can actually reach every control.

node tests/holdem/runUiAudit.js          # needs Playwright and the site running

It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large enough not to trigger iOS zoom, that nothing important is covered or off-screen with no way to scroll to it, that no grid column has collapsed to nothing, that no text is painted outside its box, that no seat covers another seat's writing, and that no bet chip is hidden behind a seat or another chip. It runs over the table, the raise pad, all five overlays and Quick Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same phones on their side — each viewport twice, once with a mouse and once with a thumb, and at three table shapes, mid-hand: the sweep posts blinds, deals and calls three times before it measures anything, so the bets layer has chips on it. It used to audit a table nobody had ever bet at, which is a third of what is on the felt during a hand left unlooked at.

The raise pad is newer than the rest and was worth every second it costs. It is part of the bar rather than an overlay, so nothing here had a step that opened it, and it is both the tallest thing the bar ever holds and the only place in the tool a dealer types an amount. The first sweep that opened it found a 36px-tall field on every phone in the list: the hover: none rule that sizes fields for a thumb names the settings panels and the seat form, and the one field on the one surface built for thumbs was not in the list. It also found the rail squeezing its own status line under the pad, which is the kind of thing that only happens in the state nobody was looking at.

"Covered" and "out of view" are told apart before anything is hit-tested. An element clipped away by a panel that scrolls is not covered by whatever happens to be painted at that height, and reporting it that way is a diagnosis of something that isn't happening: the pot wizard's third winner, below the fold in a list, came back for four viewports running as "fully covered by button Confirm Winner(s)".

Text is measured where it was painted, not where its box is. The two are different things and the difference is not academic: a name is centred, so its box is wider than its ink, and a name kept clear of the dealer's chip by a float is out of the way on the line beside the chip and full width on the line below. The check used to model that as "box minus padding", which is one way of keeping text out of somewhere and not the only one — it called every seat in the sweep an offender while the text sat exactly where it was meant to. Range rects ask the question directly, so padding, floats, centring, wrapping and a short label in a wide box all come out right without any of them being modelled.

Offenders say how much is covered rather than that something is. "Is under a badge" reads the same for a stack a dealer cannot read and for a badge's rounded corner grazing the last digit, and the difference decides whether it is worth fixing. Probing the whole line and counting turned the residual this sweep has carried for three rounds into "1/7 under SB" — one probe of seven, on one configuration, in a thumb context only.

More than one table shape is also newer than it sounds. The sweep ran on one — five players at a nine-handed table — for its whole life, and how much room the felt has depends on how many people are sitting down far more than on anything a viewport can report. A full table is where the seats crowd and it had never been looked at: the first sweep that did found one player's name or stack painted under another on eighteen of the configurations, on every phone in the list.

The third shape is the same five players with the board turned on and five cards on it. It is deliberately the same roster in the same chairs as the second, so the only thing that differs between the two rows is the board — a seventh player would have told us that a seven-handed table crowds, which is true, already known, and not what that row is there to find out. It earned its place immediately: the board takes the middle of the felt, or a strip above it where the middle is too small, and the strip costs the felt height. The seats follow a felt that changes size on their own, being placed in percentages of it; the chips anchored inside them do not, being placed in pixels. They were laid out for a felt 23px taller than the one they were painted on.

What the sweep taught the layout

For a long run of rounds the seat ring was predicted onto the table: work out the widest ring whose outermost puck still finishes inside the rail, place the seats, and trust it. The prediction is correct arithmetic and it was wrong on the page anyway, because every input to it moves after it is taken — the width search settles on a narrower puck, a name wraps to a second line or gets shortened out of one, the board appears in the middle. Each of those is up to 15px of puck height arriving after the answer. Ninety-six of the ninety-eight offenders this sweep could find were that.

So render.js now finishes the way the audit judges it. After the seats are placed it measures where they actually landed, and if the worst of them hangs 6px past the rail the ring comes in by 6px and they are placed again — the same question this file asks, asked by the code that has to get it right. Two passes, bounded, because each one is a forced layout of the whole felt and it runs after every tap a dealer makes.

That is the general shape of the lesson here: a layout that predicts itself has to be right about the future, and a layout that measures itself only has to be right about the present.

That last part, and two of those checks, are newer than this paragraph claiming them. runAll() did not include the contrast check at all, and touch targets sat behind an opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than eleven for a long run of rounds. Both were written and tested and simply never called. The mouse/thumb pair matters for the same reason: the 44px rule stands itself down where hover: hover says there is a pointer, and a headless desktop browser is a pointer, so a single pass had never measured a touch target at any viewport. Between them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete button on Quick Split.

A check that decides it doesn't apply says so in the output rather than reporting no offenders, which reads identically to a pass. The same goes for the two exemptions that exist: a disabled control, which WCAG itself excuses, and a link inside a sentence, whose size is set by the line it sits in.

Deliberately not in CI: the unit suite is dependency-free and runs in about five seconds, while this one needs a browser and a running site. Run it after any change that adds or resizes a control. With no Playwright to hand, paste uiAudit.js into devtools and call LayoutAudit.runAll().

The contrast check is built so it cannot report a false positive: backgrounds composite through alpha, and where a gradient is involved every colour stop is tried and the most flattering one used. A finding means the text fails even under the reading most generous to it. Two earlier attempts at this got it wrong in both directions — one read only background-color and scored text on a gradient against nothing, the other treated low-alpha stops as opaque — so uiAudit.js is checked against known-answer cases before it is believed.

Touch targets are measured by probing outward with elementFromPoint rather than by reading the element box, because the box lies in both directions: a small control can be padded out with a transparent ::after and respond well beyond its painted size, and a checkbox inside a label is hit by tapping the label. The 44px rule is only enforced where hover: none says there's no mouse.

It exists because these checks were being re-typed into a console every time something changed, and each time they only covered whatever had just been touched. That is how the standings table shipped with a player-name column zero pixels wide and kept it for two rounds.

Why the hand evaluator is tested the way it is

handRank.js is the only module here that can pay the wrong person without anybody noticing. A pot wizard that asks "who won?" is never more wrong than the dealer ticking the box; one that answers the question itself is wrong on its own account, silently, and a table that has come to trust it will not check.

So besides the hand-written cases — every category against every other, the wheel, three kickers deep, the counterfeited two pair, the board that plays — it is checked against a second evaluator written a different way, on twenty thousand random seven-card hands and four thousand random pairs of them. The second one is in the test file and shares no helper with the module: it scores a hand by counting ranks into a sortable list rather than by asking about shapes. That pass is most of the suite's running time and it is worth it; three deliberately introduced bugs — an ace that keeps its value in the wheel, a dropped two-pair kicker, a flush read only by its top card — are each caught by it as well as by the cases written for them.

Why the randomised suite exists

Every correctness bug found in this tool so far has been shape-dependent: blinds posted to empty chairs, the button skipping past players, heads-up acting out of order, an overbet announced as a side pot. None of them show up on a full six-handed table with equal stacks, which is the table anyone writes a fixed example for.

invariants.test.js deals a thousand hands across random seat counts, random empty chairs, random stack depths and random dealer input, and checks the things a dealer would notice being wrong:

  • chips are never created or destroyed during play
  • the pot breakdown plus the uncalled returns always equal the money that went in
  • paying the pot out settles it to the cent
  • once uncalled chips are handed back, nobody is still betting above the whole table
  • the button and both blinds always land on players who are actually in the hand

The generator is seeded, so a failure reproduces from the seed printed in the message rather than being a flake to re-run.