// 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(/]*>/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); }); });