# 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. ```bash 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 | | `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) | | `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) | | `sidebarPanels.test.js` | What the sidebar's panels are called and what lives in each (see below) | | `chipLegend.test.js` | Removing a chip colour, and whether the warning's number is the one the table loses (see below) | | `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. ```bash 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. ## Which rule wins `feltShape.test.js` is here because of a change that measured as correct and was a no-op. The felt on a phone was made an upright oval by setting `--feltAspect` inside `@media (max-width: 700px)`, and forty lines below it `@media (max-width: 900px)` set the same property back to 1. Both queries match a phone, the declarations have the same specificity, and the later one wins — so the property was 1 on every phone on the site while the rule that was supposed to change it sat there reading perfectly. Nothing in the suite could have caught it and nothing in review did. A stylesheet is not a list of rules for viewports; it is a list of rules in an order, and the question "what does a 390px phone get?" has an answer that no single rule knows. So the test walks the media queries the way the cascade does — every declaration of a property on a selector, in source order, keeping the last one whose query matches — and asks what each of five real phone sizes actually ends up with. It carries just enough of a media-query engine for the forms this stylesheet uses, and refuses loudly rather than guessing when it meets one it doesn't know. The same file records a second thing, which is a floor that was added and removed. The `max-height` that keeps the felt above the action bar has no lower bound, so a tall enough bar presses the table flat; flooring it at the desktop oval looked obviously right. Measured, it was worse: twelve players all in with nine side pots takes the bar to 341px, which at 360x640 leaves 142px of room, and a felt floored to 242px keeps its shape by putting five of the twelve seats behind the bar. Unfloored it is flat and all twelve are visible. The slit the floor defended against turned out to be unreachable — it came from forcing the custom property by hand. A test now asserts the floor is *absent*, with the numbers, so it doesn't get re-added by someone reasoning the same way. ## A property the wording depends on `potBreakdown.test.js` covers how the pot breakdown says who can win each pot, and it leans on something `potCalculator.test.js` now proves: **pot eligibility is nested.** The main pot is everyone still live, and each side pot above it drops whoever was all-in below, so every side pot's players are a strict subset of the pot beneath it. That makes "who is in" and "who is out" complements, which is what lets the line name whichever of the two is shorter and never spell out more than half the table. Listing every eligible player instead meant the main pot's line was every name at the table and each side pot repeated it minus one — and the lines too long to fit were exactly the ones nobody needed, while the short ones at the bottom were the side pots a dealer actually announces by name. The nesting check runs 20,000 random tables, and it asserts on its own generator as well as on the code: if a change to the shapes stopped producing side pots, every assertion in the loop would be vacuous and the test would still pass, so it also requires that more than ten thousand of those tables had one. Writing that check is where a comparison against `Set.length` went in — a `Set` has `.size`, so `here.length < below.length` was `2 < undefined`, which is false. As an assertion it failed everything; as a filter in a throwaway script it had passed everything, which is how it survived long enough to be written down. Both readings are silent. Prefer asserting the count you expect over comparing two things whose shapes you have not checked. ## Testing a fault that broke nothing `sidebarPanels.test.js` reads `TexasHoldem.cshtml` rather than any JavaScript, because the fault it covers was not a bug. Every control worked and every id resolved. What was wrong was which panel a thing was in and what that panel was called. The sidebar was Table Setup, **Session**, Chip Legend, Keyboard and Table Notes, and Session held the standings, a waiting list, the hand log and the save/load/reset buttons — a topic, a task, a log and three utilities under one word. It measured 1206px and 103 visible elements against 555 and 29 for the next biggest, and "The table itself" sat 1052px down inside it, so reaching Reset table meant opening a panel called Session and scrolling past the money and the log to find it. Three things are asserted, and each of them is a claim a future edit could quietly undo: - **The names say what is inside them.** The list is checked literally, because the failure mode is a word like "Session" that could hold anything. - **Each name is a heading.** A `` is announced as a button, so the sidebar's names — its whole navigation — were not reachable by heading. Splitting Session also removed the four subheads that had been the only headings down there, so without a heading in each summary the page would have come out of this with a worse outline than it went in with. - **No panel goes back to being a grab-bag.** Not a size limit — Table Setup is legitimately long. It counts *subjects*: a panel that needs its own headings to list its parts is holding more than one thing. Two subheads survive, and the test names them, because Settle up is the standings at the end of the night and Stacks in chips is the chip legend counted out — the same subject at a different moment, which is what a subhead is for. The panels are split on the opening tag rather than parsed, which is sound only because a sidebar panel never contains another one; Table Setup's sub-panels carry a different class. That assumption is worth knowing about if one ever does. ## A warning is only worth having if its number is right `chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled 24px button and was never a change to a legend. `computeContribution()` prices a seat by running the legend over that seat's chip counts, so a colour that leaves takes every chip counted in it off the table. Measured in a browser on a three-handed table: deleting the $500 row took **$22,500 of $26,874** with it, with no confirmation, nothing said afterwards, and no way back — a colour added again is a new colour with a new id, while the counts stay keyed to the one that left. Most of the file is about the number in the warning rather than the removal, because a warning that overstates is worse than none. The obvious way to work it out — chips times value — is wrong in **both** directions: - a seat with a typed amount is priced from that and its counts are never read, so a colour it holds is worth nothing to it; - a seat whose chips all leave falls through to the table's standing ante rather than to zero, so it loses less than its chips were worth. So the figure is the difference between pricing every seat with the colour and without it, and a randomised check across 2,000 tables asserts that what the warning claims is exactly what the table then does. That check found a third case nobody would have written by hand: fourteen $1 chips against a $200 ante, where removing the colour puts the table **up** $186. The wording says so in that direction too. The friction is conditional on purpose. A colour nobody has counted deletes on one click; only one that is holding money asks. A confirmation that fires every time is one nobody reads by the third night, and this is a tool where four other confirmations already compete for that attention. ## Every control says what it is `uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the × had no accessible name, so it was announced as a button and nothing else — five of them in a column, identical, each deleting a different colour. It treats a symbol as no name even though `×` is technically text. The rule is that a name has to contain a letter or a digit, which is the difference between "×" and "Undo". Names are resolved the way the accessibility tree does it — `aria-label`, then `aria-labelledby`, then a field's wrapping or pointing `