Stop the table being told a blind the dealer never typed

Typing 0.25 into the small blind produced 250, and nothing said so.

The six setup fields are wired `input` -> save -> Render.renderAll(), and
renderAll comes back round through renderSetupFields and assigns `.value`
to the very box being typed in. The decimal point is where that turns from
a wasted render into a wrong number: a type="number" reports a half-typed
"0." as "0", so the write-back erased the point, and assigning `.value` to
a number input parks the caret at position 0, so every digit after it
landed at the front. Measured keystroke by keystroke in Chrome:

  small blind   "0.25"   0 -> 0 -> 20 -> 250        a table playing 250/500
  big blind     "0.50"   0 -> 0 -> 50 -> 500
  start stack   "12.50"  1 -> 12 -> 12 -> 512 -> 5012
  rake percent  "2.5"    2 -> 2 -> 52               a 52% rake
  rake cap      "7.50"   7 -> 7 -> 57 -> 507
  base pot      "0.75"   0 -> 0 -> 70 -> 750

Every one of those is a valid number, so no validation could have caught
it, and every pot for the rest of the night is computed off it.

Two rules, kept separate. Render.setField refuses to write into the
control that has focus — a render says what the state is, and is not
entitled to say what somebody is half-way through typing. And the fields
come off type="number", which is what made a half-typed number unreadable
to begin with; it also drops characters silently, hides selectionStart,
and puts a spin button in a 54px box where five digits already need 45 of
the 52. That is the control GOV.UK dropped from their number patterns
after user testing. inputmode is what summons the keypad on a phone, and
"decimal" carries the separator a dealer needs to type 0.25 at all.

With type="text" a stray letter now reaches the handler instead of being
eaten, so Money.readAmount reads the boxes: empty is null, non-finite is
null, and NaN never becomes a blind. A field skipped while focused catches
up when it is stepped out of.

After, on the same six: 0.25, 0.50, 12.50, 2.5, 7.50, 0.75 — and the
panel glance reads "10 seats - $0.25/$0.50".

1385 tests pass (23 new, 19 of which fail against the code they replace).
Sweep clean across 428 audits on 13 viewports; contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-15 06:44:44 -06:00
co-authored by Claude Opus 5
parent 6a59b0d5ba
commit f841ccf425
6 changed files with 441 additions and 33 deletions
+272
View File
@@ -0,0 +1,272 @@
// Typing 0.25 into a box and getting 0.25.
//
// The six setup fields are wired `input` -> save -> Render.renderAll(), and renderAll
// comes back round through renderSetupFields and assigns `.value` to the very box being
// typed in. The decimal point is where that turns into a wrong number rather than a
// wasted render, because a `type="number"` reports a half-typed "0." as "0" — so the
// write-back erased the point, and assigning `.value` to a number input parks the caret
// at position 0, so every digit after it landed at the front.
//
// Measured in Chrome against the page as it stood, keystroke by keystroke:
//
// small blind "0.25" 0 -> 0 -> 20 -> 250 a table playing 250/500
// big blind "0.50" 0 -> 0 -> 50 -> 500
// start stack "12.50" 1 -> 12 -> 12 -> 512 -> 5012
// rake percent "2.5" 2 -> 2 -> 52 a 52% rake
// rake cap "7.50" 7 -> 7 -> 57 -> 507
// base pot "0.75" 0 -> 0 -> 70 -> 750
//
// Nothing warned: every one of those is a valid number, so no validation could have
// caught it, and the dealer's own table is the only place it shows.
//
// Two rules hold it shut, and they are separate on purpose. One stops a render writing
// over somebody's half-finished typing at all. The other takes the fields off
// `type="number"`, which is what made a half-finished number unreadable in the first
// place — and is what GOV.UK's design system concluded after user testing, for this and
// four other reasons.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Render, Money } = require("./load");
const MARKUP = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
// A stand-in for one form control, and a document that knows which one has focus.
const field = (value = "") => ({ value, checked: false });
const withFocus = focused => {
global.document = { activeElement: focused || null };
};
test.afterEach(() => { delete global.document; });
// ---------------------------------------------------------------------------
describe("a render does not type for you", () => {
test("the box somebody is standing in is left exactly as they left it", () => {
// The whole fix in one assertion. "0." is not a number and state has read it as
// 0; writing that 0 back is what ate the point.
const box = field("0.");
withFocus(box);
Render.setField(box, 0);
assert.equal(box.value, "0.",
"the render wrote over a half-typed number, which is how 0.25 became 250");
});
test("and the box nobody is in is brought up to date", () => {
const box = field("2");
withFocus(null);
Render.setField(box, 5);
assert.equal(box.value, "5");
});
test("a value that has not changed is not written at all", () => {
// Assigning an identical value still moves the caret in some engines, so this
// is part of the guard rather than a saving.
const box = field("5");
let writes = 0;
Object.defineProperty(box, "value", {
get: () => "5",
set: () => { writes += 1; },
});
withFocus(null);
Render.setField(box, 5);
assert.equal(writes, 0, "an unchanged value was assigned anyway");
});
test("null and undefined clear the box rather than spelling themselves out", () => {
withFocus(null);
const a = field("9");
const b = field("9");
Render.setField(a, null);
Render.setField(b, undefined);
assert.equal(a.value, "");
assert.equal(b.value, "", "an unset blind would have read the word 'undefined'");
});
test("and a box that isn't there is not an error", () => {
withFocus(null);
assert.doesNotThrow(() => Render.setField(null, 5));
assert.doesNotThrow(() => Render.setToggle(null, true));
});
});
describe("the same rule for a switch", () => {
test("a checkbox somebody is on is not flipped under them", () => {
const box = field();
box.checked = true;
withFocus(box);
Render.setToggle(box, false);
assert.equal(box.checked, true,
"a render flipped a switch the dealer had their finger on");
});
test("and one nobody is on keeps up with the state", () => {
const box = field();
box.checked = true;
withFocus(null);
Render.setToggle(box, false);
assert.equal(box.checked, false);
});
});
describe("what a typed-in box is worth", () => {
// The fields are type="text" now, so what arrives is what was typed — including
// things that are not numbers. Number("abc") is NaN, and NaN must not become a blind.
test("a number is worth that number", () => {
assert.equal(Money.readAmount("0.25"), 0.25);
assert.equal(Money.readAmount("12.50"), 12.5);
assert.equal(Money.readAmount("200"), 200);
});
test("nothing typed yet is not zero", () => {
// Null and 0 mean different things to the fields that use this: an unset
// starting stack falls back to the table default, a zero one does not.
assert.equal(Money.readAmount(""), null);
assert.equal(Money.readAmount(" "), null);
assert.equal(Money.readAmount(null), null);
assert.equal(Money.readAmount(undefined), null);
});
test("and neither is something that is not a number", () => {
["abc", "1.2.3", "$5", "--", "1,5"].forEach(text => {
assert.equal(Money.readAmount(text), null,
`"${text}" came back as something other than null`);
});
});
test("infinity is not a stack", () => {
assert.equal(Money.readAmount("Infinity"), null);
assert.equal(Money.readAmount("1e999"), null);
});
test("a half-typed number is read as what it is so far", () => {
// "0." arrives here now instead of being flattened to "0" by the control. It is
// worth 0 at this instant, and the next keystroke will say otherwise — what
// matters is that the box still reads "0." when it does.
assert.equal(Money.readAmount("0."), 0);
assert.equal(Money.readAmount(".5"), 0.5);
});
test("and it comes back rounded to the cent the tool works in", () => {
assert.equal(Money.readAmount("0.005"), 0.01);
assert.equal(Money.readAmount("1.004"), 1);
});
test("a negative is left alone rather than quietly clamped", () => {
// A typed "-5" is a different mistake from a mistyped one, and the callers that
// care clamp for themselves. Folding it in here would change what the dealer
// asked for without saying so.
assert.equal(Money.readAmount("-5"), -5);
});
});
describe("the boxes themselves", () => {
const inputs = MARKUP.match(/<input\b[^>]*>/g) || [];
const attr = (tag, name) => {
const m = tag.match(new RegExp(`${name}="([^"]*)"`));
return m ? m[1] : null;
};
const named = tag => attr(tag, "id") || attr(tag, "class") || tag.slice(0, 40);
test("none of them is a type=number any more", () => {
const spinners = inputs.filter(t => attr(t, "type") === "number");
assert.deepEqual(spinners.map(named), [],
"a type=number is back: it reports a half-typed '0.' as '0', which is the " +
"read that let a render write the decimal point away");
});
test("every box that takes digits says which keypad it wants", () => {
// The digits fields are the ones this round converted. Identified by name rather
// than by type, because their type is exactly what no longer distinguishes them.
const DIGITS = [
"seatCountInput", "smallBlindInput", "bigBlindInput", "defaultStackInput",
"rakePercentInput", "rakeCapInput", "basePotInput",
"stackInput", "amountOverrideInput", "chipValueInput", "chipCountInput",
"addChipsInput",
];
DIGITS.forEach(name => {
const tag = inputs.find(t => named(t) === name);
assert.ok(tag, `${name} is gone from the markup`);
const mode = attr(tag, "inputmode");
assert.ok(mode === "decimal" || mode === "numeric",
`${name} has inputmode="${mode}" — without one a phone offers a ` +
`full keyboard for a number, which is the slowest thing on the page`);
});
});
test("the money ones ask for the separator, the counting ones do not", () => {
// "decimal" carries the reader's decimal separator; "numeric" is bare digits.
// A blind needs the point. A seat count would only be a way to type a wrong one.
const modeOf = name => attr(inputs.find(t => named(t) === name), "inputmode");
["smallBlindInput", "bigBlindInput", "defaultStackInput", "rakeCapInput",
"basePotInput", "stackInput", "chipValueInput"].forEach(name => {
assert.equal(modeOf(name), "decimal", `${name} cannot be given cents`);
});
["seatCountInput", "chipCountInput"].forEach(name => {
assert.equal(modeOf(name), "numeric", `${name} counts things, not money`);
});
});
test("and none of them invites the browser to fill it in", () => {
// A saved address offering itself as a small blind is the browser being helpful
// about the one field on the page it cannot know anything about.
["smallBlindInput", "bigBlindInput", "defaultStackInput", "seatCountInput",
"rakePercentInput", "rakeCapInput", "basePotInput"].forEach(name => {
const tag = inputs.find(t => named(t) === name);
assert.equal(attr(tag, "autocomplete"), "off", `${name} may be autofilled`);
});
});
});
describe("what still counts as a number box", () => {
// Money.selectsOnFocus decides whether tapping a pre-filled amount box selects what
// is in it. It used to recognise those boxes by type="number"; there are none left.
test("a text box with a decimal keypad is one", () => {
assert.equal(Money.selectsOnFocus(
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), true);
});
test("and so is one with a numeric keypad", () => {
// The seat count and the chip count. Both open pre-filled, both were caught by
// the old type check, and reading only "decimal" would have dropped them.
assert.equal(Money.selectsOnFocus(
{ tagName: "INPUT", type: "text", inputMode: "numeric" }), true);
});
test("a plain text box is not", () => {
assert.equal(Money.selectsOnFocus(
{ tagName: "INPUT", type: "text", inputMode: "" }), false,
"a player's name would be wiped by the first keystroke after a tap");
});
});
describe("a wheel has nothing left to spin", () => {
test("no field in the tool is a spinbutton any more", () => {
// wheelWouldEdit answers false for every field now, which is the point rather
// than a regression: a text box is not a spinbutton, so a wheel over it cannot
// change it. The guard stays because it is what makes that safe to rely on.
assert.equal(Money.wheelWouldEdit(
{ tagName: "INPUT", type: "text", inputMode: "decimal" },
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), false);
});
test("but it would still catch one if somebody added it back", () => {
const spinner = { tagName: "INPUT", type: "number" };
assert.equal(Money.wheelWouldEdit(spinner, spinner), true);
});
});