Last change made a failing save say so. This is the other direction, and it turned out to be worse. load() is well defended -- try/catch, a shape check, sensible fallbacks -- and every one of those paths ends in `return this.createDefault()`. So an unreadable save becomes an empty table. Driven with a save truncated to 70% of its length, the way an interrupted write leaves one: 1762 bytes stored, 1233 after truncating loaded: 0 named seats, hand #1 instead of #34 toast: none. warning: none. anything on the page: no. A dealer mid-session sees an empty felt and cannot tell whether they mis-tapped Reset, whether the browser lost the night, or what. And then the first edit destroys the evidence. I typed a name into a seat and storage went from the 1233 unreadable bytes to 2549 bytes of fresh default state. Whatever a person could have picked out of that JSON -- the names, the buy-ins, the standings -- was gone one tap after the tool decided it could not read it. Now the unreadable value is moved aside to its own key before the fallback, and the table says what happened: toast: "The saved table couldn't be read, so this one started empty." panel: "The saved table couldn't be read, so this one started empty. The unreadable copy has been kept rather than written over." It is deliberately not offered as a restore. Half a JSON document is not a table, and pretending otherwise would be worse than an empty one. It just stops being destroyed. The two warnings share the Session panel line and both can show at once -- a browser having trouble with storage tends to have it in both directions -- with the load message first, because "why is my table empty" is the question the dealer is standing there asking. 8 tests (263 total): nothing stored is a new table rather than a failure, a good save loads clean, a truncated one is reported, valid JSON of the wrong shape counts too, the copy is kept, a later save does not touch it, the listener fires once across three reads, and storage with no room to keep the copy is not a crash -- which is exactly the storage most likely to hand back half a value in the first place. Driven through the UI: warning and toast on a corrupted save, the set-aside copy still 1233 bytes intact after editing a seat, and nothing shown at all on a healthy table. One audit correction rides along. The collapsed-column check measured inside hidden elements, so the standings header -- hidden with its table until somebody buys in -- was reported as three collapsed columns on every empty table. It skips hidden subtrees now, and still catches a real one: squeezing a visible column reports the header and all three rows. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
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 |
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, and that no text is painted outside its box. It runs over the table, all five overlays and Quick Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same phones on their side.
Deliberately not in CI: the unit suite is dependency-free and runs in a second, 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 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.