A focused <input type="number"> is a spinbutton, and a browser lets the mouse wheel drive it. Measured on the running page at 1280x900 with the sidebar open: focus the small blind and scroll, $5 becomes $5.50. Focus a seat's stack and scroll, $500 becomes $501. Both go straight through to localStorage, because these fields save on `input` like everything else here. Nothing is announced and nothing looks wrong. The tool has invented a chip, on a table whose whole purpose is that the chip count is right, and the gesture that did it was a dealer reaching for another panel. The rule is narrow on purpose. Every other way of driving a spinbutton is deliberate -- the arrow keys, the stepper arrows, typing -- so scrolling is the only one taken away. Verified after: the value holds, one ArrowUp still moves it, typing still works, and the page still scrolls. Blur rather than preventDefault. Cancelling the event stops the value moving and stops the page moving with it, which trades a silent bug for a sidebar that mysteriously will not scroll -- a problem the dealer can see and cannot explain, instead of one they cannot see at all. The decision lives in Money as a predicate beside selectsOnFocus, so the rule is testable without a browser and main.js only does the blurring. Two things checked rather than assumed. The tagName guard in the predicate is decoration: deleting it passes every test, because nothing but an <input> carries type="number". That is now said in the comment instead of being left to look load-bearing. And the page's one <select> -- which way the blinds move -- is not vulnerable: focused, hovered and scrolled 300px in Chromium it stays where it was, so it is left out of the rule rather than defended against. 916 tests pass, 6 of them new. Four fault injections into the predicate are caught; a fifth was applied, not caught, and turned out not to be a fault. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
120 lines
5.4 KiB
JavaScript
120 lines
5.4 KiB
JavaScript
// Which fields replace their contents when a dealer reaches for them.
|
|
const test = require("node:test");
|
|
const { describe } = require("node:test");
|
|
const assert = require("node:assert");
|
|
const { Money } = require("./load");
|
|
|
|
// Enough of an input to answer the question, without a DOM.
|
|
const field = (props = {}) => ({ tagName: "INPUT", type: "text", inputMode: "", ...props });
|
|
|
|
test("a number box selects", () => {
|
|
assert.equal(Money.selectsOnFocus(field({ type: "number" })), true);
|
|
});
|
|
|
|
test("a decimal keypad box selects", () => {
|
|
// The raise pad's amount is type=text with inputmode=decimal, so the phone shows
|
|
// a number pad without the browser's spinner arrows.
|
|
assert.equal(Money.selectsOnFocus(field({ type: "text", inputMode: "decimal" })), true);
|
|
});
|
|
|
|
test("a name does not", () => {
|
|
// Selecting all of a name on focus is the same mistake pointing the other way:
|
|
// a name is a thing you edit, not a suggestion you replace.
|
|
assert.equal(Money.selectsOnFocus(field({ type: "text" })), false);
|
|
});
|
|
|
|
test("a colour swatch does not", () => {
|
|
assert.equal(Money.selectsOnFocus(field({ type: "color" })), false);
|
|
});
|
|
|
|
test("a checkbox does not", () => {
|
|
assert.equal(Money.selectsOnFocus(field({ type: "checkbox" })), false);
|
|
});
|
|
|
|
test("a textarea does not, whatever it is asked", () => {
|
|
assert.equal(Money.selectsOnFocus({ tagName: "TEXTAREA", type: "text", inputMode: "decimal" }), false);
|
|
});
|
|
|
|
test("a select does not", () => {
|
|
assert.equal(Money.selectsOnFocus({ tagName: "SELECT", type: "select-one" }), false);
|
|
});
|
|
|
|
test("case doesn't matter", () => {
|
|
assert.equal(Money.selectsOnFocus(field({ type: "NUMBER" })), true);
|
|
assert.equal(Money.selectsOnFocus(field({ type: "TEXT", inputMode: "DECIMAL" })), true);
|
|
});
|
|
|
|
test("nothing at all is not a field", () => {
|
|
assert.equal(Money.selectsOnFocus(null), false);
|
|
assert.equal(Money.selectsOnFocus(undefined), false);
|
|
});
|
|
|
|
test("a field with no type stated is a text box, so it does not", () => {
|
|
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
|
|
});
|
|
|
|
// ---- A scroll is not an edit ------------------------------------------------
|
|
//
|
|
// A focused `<input type="number">` is a spinbutton, and the browser lets the mouse
|
|
// wheel drive it. Measured on the running page at 1280x900 with the sidebar open: focus
|
|
// the small blind and scroll, $5 becomes $5.50. Focus a seat's stack and scroll, $500
|
|
// becomes $501. Both go straight through to the saved table, because these fields save
|
|
// on `input` like everything else here.
|
|
//
|
|
// Nothing is announced and nothing looks wrong. The tool has invented a chip, and the
|
|
// gesture that did it was a dealer reaching for another panel.
|
|
//
|
|
// The rule is narrow on purpose. Every other way of driving a spinbutton is deliberate —
|
|
// the arrow keys, the stepper arrows, typing — and none of them is touched. Scrolling is
|
|
// the one actuation nobody means.
|
|
describe("a wheel over a field the dealer is in", () => {
|
|
const box = (type, tag = "INPUT") => ({ tagName: tag, type });
|
|
|
|
test("a focused number box would be edited by it", () => {
|
|
const stack = box("number");
|
|
assert.equal(Money.wheelWouldEdit(stack, stack), true);
|
|
});
|
|
|
|
test("the same box, not focused, would not", () => {
|
|
// An unfocused spinbutton ignores the wheel. There is nothing to defend, and
|
|
// blurring something that is not focused is a no-op with a side effect.
|
|
const stack = box("number");
|
|
assert.equal(Money.wheelWouldEdit(stack, null), false);
|
|
assert.equal(Money.wheelWouldEdit(stack, box("number")), false);
|
|
});
|
|
|
|
test("the amount boxes that already moved off type=number are safe as they are", () => {
|
|
// Add chips, the raise pad and Quick Split use text + inputmode="decimal", which
|
|
// is not a spinbutton and cannot be scrolled into a different number.
|
|
const amount = { tagName: "INPUT", type: "text", inputMode: "decimal" };
|
|
assert.equal(Money.wheelWouldEdit(amount, amount), false);
|
|
// Still an amount field for the other rule, though — the two questions are
|
|
// different and this one must not quietly answer both.
|
|
assert.equal(Money.selectsOnFocus(amount), true);
|
|
});
|
|
|
|
test("a name, a note and a table are left alone", () => {
|
|
const name = { tagName: "INPUT", type: "text" };
|
|
const notes = { tagName: "TEXTAREA", type: "textarea" };
|
|
assert.equal(Money.wheelWouldEdit(name, name), false);
|
|
assert.equal(Money.wheelWouldEdit(notes, notes), false);
|
|
assert.equal(Money.wheelWouldEdit(null, null), false);
|
|
assert.equal(Money.wheelWouldEdit(undefined, undefined), false);
|
|
});
|
|
|
|
test("the type is read case-insensitively, the way an attribute comes back", () => {
|
|
const shouty = box("NUMBER");
|
|
assert.equal(Money.wheelWouldEdit(shouty, shouty), true);
|
|
});
|
|
|
|
test("a select is a spinbutton to nobody", () => {
|
|
// The page has one <select> — which way the blinds move — and it was the obvious
|
|
// next suspect. It isn't one: focused, hovered and scrolled 300px in Chromium it
|
|
// stays on "clockwise", because a select only takes the wheel while its dropdown
|
|
// is open. Checked rather than assumed, and left out rather than defended
|
|
// against, so this rule keeps meaning exactly one thing.
|
|
const select = { tagName: "SELECT", type: "select-one" };
|
|
assert.equal(Money.wheelWouldEdit(select, select), false);
|
|
});
|
|
});
|