Files
JoshHeaps.Net/tests/holdem/theHeader.test.js
T
jheapsandClaude Opus 5 f86e50268e Stop calling it the next hand when it deals no hand
"Next hand" named three things: the bar's green button, the pot wizard's
last button, and the header shortcut — the only one of the three that
never starts a hand. Between hands it moves the button a second time, so
the seat about to pay the big blind is skipped, and the toast opened with
the words the wizard had just used, which read as a confirmation of what
was already done.

Measured: settle through the wizard and the table is on hand 6 with the
button on seat 2; press the header's Next Hand and it is still hand 6,
button on seat 3, no confirmation asked.

The mechanism stays — a dealer does pass the button along on a hand
nobody played. The name changes. The label and its title are now read off
whether a hand is out: "Next Hand ⟳" while one is, "Move Button ⟳" when
none is, written past the dimmed-reason cache each render so they follow
the table instead of freezing on the state the page loaded in. The toast
says which of the two happened, on one line: the second line told the
dealer to press the green button the bar is already holding.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 04:17:32 -06:00

176 lines
9.6 KiB
JavaScript

// What stands between the top of a phone and the felt.
//
// The header was 120px of a 390x844 phone — 16% of the window, spent before the table
// started — and only 46 of that was a control. The other 74 were a 22px page title with
// a gold rule under it, a back link, and a gold "Hand #1" pill: three things read once,
// above a screen whose subject is the green table underneath.
//
// A phone held sideways had already solved this. The landscape block clips the title and
// puts everything on one row, because at 390px tall there was no other option. Upright
// never got the same answer, and upright is how this tool is held: measured after, the
// header is 44px and the felt went from 496px to 570.
//
// Two claims are checked here. The stylesheet's, as text — the same way the seat sheet's
// and the reduced-motion rules are — because "which row is this on" is a layout decision
// that lives in the file. And the action bar's, by running it: the hand number had to go
// somewhere, and where it went is a rendering question.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { ActionBar, makeTable } = require("./load");
const PAGE = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// Every @media block written at exactly this condition, concatenated.
//
// All of them, not the first one — `@media (max-width: 700px)` is opened twice in this
// file, the second time to hold the phone layout proper, and a helper that stopped at
// the first found a block with none of these rules in it and reported them missing.
const mediaBlock = condition => {
const found = [];
const needle = "@media ";
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
const open = CSS.indexOf("{", at);
if (CSS.slice(at + needle.length, open).trim() !== condition) continue;
let depth = 0;
for (let i = open; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(open + 1, i)); break; }
}
}
assert.ok(found.length > 0, `no @media block written as "${condition}"`);
return found.join("\n");
};
const ruleIn = (block, selector) => {
const re = new RegExp(`(^|[},])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m");
const found = block.match(re);
return found ? found[2] : null;
};
const PHONE = () => mediaBlock("(max-width: 700px)");
// The header's sideways rules are in the plain landscape query, not the one that also
// caps the width — that later block is the seat sheet's and the action rail's. Both
// exist, and picking the wrong one finds a block with no header in it at all.
const LANDSCAPE = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
// ---------------------------------------------------------------------------
describe("the header is one row on a phone", () => {
test("upright gets the shape a phone on its side already had", () => {
// These four together are the whole change: don't wrap, drop the word beside the
// arrow, clip the title, and let the buttons size themselves in what is left.
const phone = PHONE();
assert.match(ruleIn(phone, "#holdemHeader") || "", /flex-wrap:\s*nowrap/,
"a wrapping header is a two-row header");
assert.match(ruleIn(phone, "#holdemHeaderLeft") || "", /display:\s*contents/,
"the arrow has to be a flex item of the header, not a block inside a block");
assert.ok(ruleIn(phone, ".backLinkWord"), "the word beside the arrow is not dropped");
assert.ok(ruleIn(phone, "#holdemHeader h1"), "the title is not addressed on a phone");
});
test("both narrow layouts clip the title rather than removing it", () => {
// `display: none` would take the page's only <h1> off the accessibility tree, and
// a page whose heading disappears below 700px is a page with no heading on the
// device it is mostly read on.
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
const rule = ruleIn(block, "#holdemHeader h1");
assert.ok(rule, `${which} does not address the title`);
assert.doesNotMatch(rule, /display:\s*none/,
`${which} removes the page's heading instead of hiding it`);
assert.match(rule, /clip-path:\s*inset\(50%\)/,
`${which} should clip the heading the way .visuallyHidden does`);
});
});
test("the actions row stops claiming a row of its own", () => {
// `width: 100%` is what made it a second row: a flex child at full width cannot
// share a line with anything, whatever the wrap setting says.
const rule = ruleIn(PHONE(), "#holdemHeaderActions") || "";
assert.doesNotMatch(rule, /width:\s*100%/);
assert.match(rule, /flex:\s*1/, "it should take the width the title gave up");
});
test("and the buttons grow into that width without ever shrinking below their labels", () => {
// `flex: 1 1 0` would divide the row into equal shares: at 360px the row is 269
// wide, so mid-hand that is 87px each and "Next Hand ⟳" needs 89 — 73 of text and
// 16 of padding. These are `white-space: nowrap`, so what gives is the text.
const rule = ruleIn(PHONE(), "#holdemHeaderActions .holdemBtn") || "";
assert.match(rule, /flex:\s*1\s+1\s+auto/, "an auto basis makes the label the floor");
assert.match(rule, /white-space:\s*nowrap/);
assert.match(rule, /min-height:\s*46px/, "across is the only direction that tightens");
});
test("the arrow keeps a thumb-sized box once it loses its word", () => {
// The word used to carry the width, and the padding only had to make up the
// height. With the word gone the arrow is the whole target, and the first
// attempt at 32px was measured by the layout sweep at 31x43 on every touch
// viewport it runs — 56 scenarios, one control.
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
const rule = ruleIn(block, ".holdemBackLink") || "";
assert.match(rule, /min-width:\s*44px/, `${which}'s back arrow is under the touch floor`);
// The negative margins that gave the hit area back to a two-row header would
// now pull the arrow out of a row it is the first item of.
assert.match(rule, /margin:\s*0/);
});
});
test("and it still says where it goes when the word is gone", () => {
// An "←" on its own is announced as "left arrow" and names nothing. The label
// contains the visible text on the sizes that still show it, which is what
// WCAG 2.5.3 asks of a name that says more than the label does.
const link = PAGE.match(/<a href="\/casino"[^>]*>/);
assert.ok(link, "the back link is gone");
const label = link[0].match(/aria-label="([^"]+)"/);
assert.ok(label, "the back link has no accessible name for when the word is hidden");
assert.match(label[1], /Casino/,
"the visible word has to appear in the spoken name");
});
});
describe("the hand number found somewhere to live", () => {
test("it is not a pill beside the page title any more", () => {
assert.ok(!PAGE.includes("handCounterBadge"),
"a counter read after the fact does not earn a line of a phone's header");
});
test("it is in the row that already says what hand you are in", () => {
// The street, the pot and the player count are the other three facts about the
// current hand, and they are drawn together. This one costs nothing there: the
// row was on the screen already.
const source = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"),
"utf8");
// Sliced from the top of renderStatus to the first mention of the street, so
// finding the hand chip inside that slice is the ordering claim as well as the
// presence one: it is appended before the street or it is not in here.
const status = source.slice(source.indexOf("renderStatus()"), source.indexOf("actionStreet"));
assert.match(status, /actionHand/,
"the status row does not say which hand this is, or says it after the street");
});
test("a table that has never dealt says #1, not #0 and not #undefined", () => {
const state = makeTable({ seatCount: 4 });
delete state.handNumber;
assert.equal(ActionBar.handLabel(state), "#1");
assert.equal(ActionBar.handLabel({ handNumber: 0 }), "#1");
assert.equal(ActionBar.handLabel({ handNumber: 12 }), "#12");
assert.equal(ActionBar.handLabel(null), "#1");
});
test("it is drawn quieter than the street beside it", () => {
// The street is read every deal; this is read when somebody asks about a hand
// that is already over. Same row, different job, and the colour says so.
const rule = CSS.match(/\.actionHand\s*\{([^}]*)\}/);
assert.ok(rule, ".actionHand has no rule");
assert.match(rule[1], /color:\s*var\(--text-dim\)/);
// A number that ticks over while the dealer is looking at it.
assert.match(rule[1], /font-variant-numeric:\s*tabular-nums/);
});
});