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
+35 -11
View File
@@ -101,24 +101,48 @@
"While you deal" below, with the paragraphs that explain them, which
is where House take and Without live betting already were: the panel
had the vocabulary for this and was only using it for money. *@
@* Every field below takes digits and none of them is type="number".
That is deliberate, and it is the second half of a fix — the first
half is Render.setField, which stopped a re-render writing over the
text somebody was still typing.
type="number" was costing more than it paid here. It silently drops
characters it dislikes with no feedback to the person or to assistive
tech; it reports a half-typed "0." as "0", which is what let the
decimal point be written away; it does not expose selectionStart, so
nothing can put a caret back where it was; it draws a spin button
into a 54px-wide blind field, where five digits already need 45 of
the 52 available; and it is the control GOV.UK dropped from their
number patterns after user testing, for the same reasons plus Dragon
and NVDA.
inputmode is what actually summons the keypad on a phone, and it is
the better keypad: "decimal" carries the separator for the reader's
locale, which is the key a dealer needs to type 0.25 and the one
type="number" leaves them hunting for on iOS.
min/max/step go with it. Nothing relied on them: the seat count is
clamped to 2..24 in main.js before it is used, and the money fields
are read through a finite-number check that a spinner never provided
anyway. *@
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
<input type="text" id="seatCountInput" inputmode="numeric" autocomplete="off" value="10" />
</label>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
<input type="number" id="smallBlindInput" min="0" step="0.5" />
<input type="text" id="smallBlindInput" inputmode="decimal" autocomplete="off" />
</label>
<label class="fieldRow">
<span>Big blind</span>
<input type="number" id="bigBlindInput" min="0" step="0.5" />
<input type="text" id="bigBlindInput" inputmode="decimal" autocomplete="off" />
</label>
</div>
<label class="fieldRow">
<span>Starting stack ($)</span>
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
<input type="text" id="defaultStackInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<label class="fieldRow">
@@ -166,11 +190,11 @@
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
<input type="text" id="rakePercentInput" inputmode="decimal" autocomplete="off" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
<input type="text" id="rakeCapInput" inputmode="decimal" autocomplete="off" placeholder="none" />
</label>
</div>
<label class="fieldRow">
@@ -191,7 +215,7 @@
<div class="subPanelBody">
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
<input type="text" id="basePotInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 500" />
</label>
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
</div>
@@ -438,7 +462,7 @@
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
<input type="text" class="stackInput" inputmode="decimal" autocomplete="off" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
@@ -462,7 +486,7 @@
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
<input type="text" class="amountOverrideInput" inputmode="decimal" autocomplete="off" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
@@ -481,7 +505,7 @@
<template id="chipLegendRowTemplate">
<div class="chipLegendRow">
<input type="color" class="chipColorInput" aria-label="Chip colour" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
<input type="text" class="chipValueInput" inputmode="decimal" autocomplete="off" placeholder="value" aria-label="Chip value" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
</div>
</template>
@@ -494,7 +518,7 @@
<label class="chipCountRow">
<span class="chipSwatch"></span>
<span class="chipCountValue"></span>
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
<input type="text" class="chipCountInput" inputmode="numeric" autocomplete="off" value="0" />
</label>
</template>
+5 -1
View File
@@ -132,7 +132,11 @@
.potLayerAmount,
.handHistoryRow,
.handHistoryPot,
input[type="number"] {
/* Was input[type="number"]. The numeric fields are type="text" with an inputmode now
— see the comment above the blinds in TexasHoldem.cshtml — and what earns a digit
column here is holding digits, which is exactly what inputmode says. */
input[inputmode="decimal"],
input[inputmode="numeric"] {
font-variant-numeric: tabular-nums;
}
@@ -563,7 +563,7 @@ document.addEventListener("DOMContentLoaded", () => {
});
document.getElementById("basePotInput").addEventListener("input", e => {
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
state.basePotAmount = Money.readAmount(e.target.value);
hooks.save(state);
Render.renderAll();
});
@@ -828,7 +828,10 @@ document.addEventListener("DOMContentLoaded", () => {
["defaultStackInput", "defaultStack"],
].forEach(([id, key]) => {
document.getElementById(id).addEventListener("input", e => {
state[key] = e.target.value === "" ? null : Number(e.target.value);
// Money.readAmount rather than Number(): these boxes are type="text" now, so
// a half-typed "0." or a stray letter reaches here instead of being silently
// eaten by the control, and NaN must not become the blind.
state[key] = Money.readAmount(e.target.value);
hooks.save(state);
Render.renderAll();
});
@@ -17,6 +17,31 @@ const Money = {
return Math.round((Number(amount) || 0) * 100);
},
// What a typed-in box is worth, or null if it isn't worth anything yet.
//
// The numeric fields are type="text" now, which is the whole point — the browser
// hands over exactly what was typed instead of silently dropping the characters it
// dislikes. The cost of that is that what was typed might not be a number, and
// `Number("abc")` is NaN, which would go into state, get saved, and come back as
// null on reload having taken the blind with it.
//
// So the reading is done in one place: empty is null, anything non-finite is null,
// and a real number comes back rounded to the cent the rest of the tool works in.
// Null rather than 0 because the fields it reads distinguish them — an unset
// starting stack falls back to the table default, a zero one does not.
//
// Deliberately not clamped to >= 0 here. A negative blind is a different mistake
// from a mistyped one, and the callers that care already clamp (the rake to 0..100,
// the seat count to 2..24); folding it in would silently turn a typed "-5" into
// something the dealer did not ask for.
readAmount(text) {
if (text == null) return null;
const trimmed = String(text).trim();
if (trimmed === "") return null;
const value = Number(trimmed);
return Number.isFinite(value) ? this.round(value) : null;
},
fromCents(cents) {
return cents / 100;
},
@@ -149,11 +174,20 @@ const Money = {
//
// Only number boxes. A name or a note is a thing you edit, and selecting all of
// it on focus would be the same mistake pointing the other way.
//
// What makes a box a number box is now the inputmode rather than the type: nothing
// in the tool is type="number" any more (see the comment above the blinds in
// TexasHoldem.cshtml). Both inputmodes count. "numeric" is the seat count and the
// chip count — whole-number boxes that open pre-filled exactly like the money ones,
// and which this used to catch through their old type. Reading only "decimal" would
// have quietly dropped them, so it reads both.
selectsOnFocus(field) {
if (!field || field.tagName !== "INPUT") return false;
const type = String(field.type || "").toLowerCase();
if (type === "number") return true;
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
if (type !== "text") return false;
const mode = String(field.inputMode || "").toLowerCase();
return mode === "decimal" || mode === "numeric";
},
// Would a wheel over this field change what it says?
@@ -166,6 +200,12 @@ const Money = {
//
// A predicate rather than a listener so the rule can be tested without a browser,
// the same way selectsOnFocus is. main.js does the blurring.
//
// As of the move off type="number" this answers false for every field in the tool,
// and that is the point rather than a bug: a text box with an inputmode is not a
// spinbutton, so a wheel over it cannot change it and there is nothing left to
// defend. Kept, tests and all, because it is the guard that makes that safe to rely
// on — reintroduce a type="number" anywhere and this starts firing again on its own.
wheelWouldEdit(field, activeElement) {
// The tag check is belt and braces and known to be so: nothing but an <input>
// carries type="number", so the line below already excludes every textarea,
@@ -21,6 +21,7 @@ const Render = {
this.lastHistorySignature = null;
this.handTimeCache.clear();
this.watchSeatSheetHeight();
this.watchFieldExits();
this.renderAll();
},
@@ -113,21 +114,79 @@ const Render = {
});
},
// ---- Writing a value into a field somebody might be standing in ----
//
// A render says what the state is. It is not entitled to say what somebody is
// half-way through typing, and when it did, it ate the decimal point.
//
// The six setup fields are wired `input` -> save -> renderAll, and renderAll comes
// back round through renderSetupFields and assigns `.value`. Type "0.25" into the
// small blind and the fourth keystroke is not the one that breaks it; the "." is:
//
// "0" -> reads "0" -> state 0 -> written back "0" (no change, fine)
// "0." -> reads "0" -> state 0 -> written back "0" <- the "." is gone
// then -> caret is at 0, because assigning .value to a number input parks it
// at the start (number inputs do not expose selectionStart at all)
// "2" -> "20"
// "5" -> "250"
//
// A dealer setting $0.25/$0.50 blinds got a table playing 250/500, silently, with
// every pot for the rest of the night computed off it. Measured on all six:
// 0.25->250, 0.50->500, 12.50->5012, 2.5->52 (a 52% rake), 7.50->507, 0.75->750.
//
// So: never write into the control that has focus. It is the one place where the
// person knows better than the state does, and the state is a keystroke behind them
// anyway. Everything they type still lands in state through the `input` handler, so
// skipping the write-back costs nothing and is not a deferral of it.
//
// This guards every control rather than the six that were caught, because the next
// field wired to a re-render is the next one to eat a decimal point, and there is
// nothing about it at the call site to warn whoever adds it.
setField(el, value) {
if (!el) return;
if (typeof document !== "undefined" && el === document.activeElement) return;
const next = value == null ? "" : String(value);
// Assigning an identical value still moves the caret in some engines, so the
// compare is part of the guard rather than an optimisation.
if (el.value !== next) el.value = next;
},
setToggle(el, on) {
if (!el) return;
if (typeof document !== "undefined" && el === document.activeElement) return;
if (el.checked !== !!on) el.checked = !!on;
},
// The other half of the rule above: a field that was skipped because somebody was
// standing in it has to catch up when they step out, or it keeps showing what they
// typed after the state has moved on (a blind that got clamped, say). Bound once,
// on the document, because the controls it covers are rebuilt from templates.
watchFieldExits() {
if (this.fieldExitWatcher || typeof document === "undefined") return;
this.fieldExitWatcher = event => {
const el = event.target;
if (!el || !el.matches || !el.matches("input, select, textarea")) return;
// After the blur has settled, so activeElement is no longer this field.
setTimeout(() => this.renderSetupFields(), 0);
};
document.addEventListener("focusout", this.fieldExitWatcher);
},
renderSetupFields() {
document.getElementById("seatCountInput").value = this.state.seatCount;
document.getElementById("directionSelect").value = this.state.direction;
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
document.getElementById("trackBoardToggle").checked = !!this.state.trackBoard;
document.getElementById("keepScreenAwakeToggle").checked = this.state.keepScreenAwake !== false;
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
this.setField(document.getElementById("seatCountInput"), this.state.seatCount);
this.setField(document.getElementById("directionSelect"), this.state.direction);
this.setField(document.getElementById("basePotInput"), this.state.basePotAmount ?? "");
this.setToggle(document.getElementById("dedicatedDealerToggle"), this.state.dedicatedDealer);
this.setToggle(document.getElementById("trackBoardToggle"), this.state.trackBoard);
this.setToggle(document.getElementById("keepScreenAwakeToggle"), this.state.keepScreenAwake !== false);
this.setField(document.getElementById("smallBlindInput"), this.state.smallBlind ?? "");
this.setField(document.getElementById("bigBlindInput"), this.state.bigBlind ?? "");
this.setField(document.getElementById("defaultStackInput"), this.state.defaultStack ?? "");
const rake = this.state.rake || {};
document.getElementById("rakePercentInput").value = rake.percent || "";
document.getElementById("rakeCapInput").value = rake.cap ?? "";
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
this.setField(document.getElementById("rakePercentInput"), rake.percent || "");
this.setField(document.getElementById("rakeCapInput"), rake.cap ?? "");
this.setToggle(document.getElementById("rakeNoFlopInput"), rake.noFlopNoDrop !== false);
// What the house has actually taken tonight, said where the rake is set rather
// than only in the bank check — a dealer who changes the percentage mid-session
@@ -2571,7 +2630,11 @@ const Render = {
const stackInput = form.querySelector(".stackInput");
stackInput.value = player.stack ?? "";
stackInput.addEventListener("input", () => {
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
// Null for empty *and* for anything that isn't a number — the box is
// type="text" now, so a stray letter arrives here rather than being eaten
// by the control, and a seat holding NaN chips is worse than one holding
// none. See Money.readAmount.
player.stack = Money.readAmount(stackInput.value);
this.commitSeatChange(form, player, index);
});
@@ -2718,9 +2781,11 @@ const Render = {
chip.color = colorInput.value;
this.hooks.save(this.state);
// The strip above is the same fact this field is editing, so it keeps up
// with it. Only the strip: renderChipLegend would write this control's
// own value back into it mid-keystroke, which is the same reason the
// list below calls its neighbours one at a time rather than renderAll.
// with it. Only the strip, still — but for cost now rather than for
// safety: setField refuses to write into a focused control, so the
// mid-keystroke clobber this was dodging can no longer happen from any
// direction. Naming the two renders that matter is simply cheaper than
// laying the felt out again on every keystroke.
this.renderChipLegendStrip();
});
valueInput.addEventListener("input", () => {
@@ -2735,8 +2800,8 @@ const Render = {
// legend's own summary is on it: shut, the panel reads "5 colours ·
// $1–$500", and re-pricing the top chip is exactly the edit that
// changes it. Called here for the same reason as the rest of the list:
// renderAll would write this field's own value back into it mid-
// keystroke.
// it is what this edit actually moves, and renderAll would lay the whole
// felt out again on every keystroke to find that out.
//
// Only that one. The bank check is not downstream of this, which is
// worth saying because it looks as though it should be — a seat's chip
+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);
});
});