On a fresh table the button is on seat 1, and seat 1 is where the first player goes. Tapping it did nothing. The dealer chip is a 22px disc, and a 22px disc is half the 44px a fingertip needs, so it carried an invisible pad to catch the tap. The pad was moved twice looking for somewhere to grow that wasn't already spoken for: outward first, where it hung over the seat next door and gave that seat the button; then inward, over "its own seat, which is a large target that can spare the corner". The seat could not spare it. Measured on a 390px phone, a 72x46 puck: the pad ran from the puck's own centre to its right edge and from its top to 3px off its bottom, and the chip's handler calls stopPropagation. The seat holding the button kept 52% of its face — 130 of 276 sampled points went to the chip, its exact middle among them. Every other seat kept 97%. The button moves every hand, so the dead puck went round the table with it. And the chip it went to had nothing to say. Its only answer is "give the button to this seat", and this is the seat that has it. The seat menu already knew that and hides "Give the button" there; mid-hand the same no-op would have taken the blinds off the felt and re-posted the same chips to announce that nothing happened. So the pad is gone rather than moved again — a fixed pad cannot be safe at every puck size, and no finger reaches any chip now: the ghosts are pointer-events: none without hover, and the one live chip is this one, which is now inert to the pointer and still there to read. Giving the button is the long-press menu on touch and the revealed ghost on a mouse, both unchanged. makeDealer returns early on the seat that already has it, so the keyboard can't re-post blinds to move the button nowhere either. The sweep had a check for exactly this — "a seat's centre belongs to that seat", written when the pad was reaching outward — and it ran clean through all of it, because `seat.contains(hit)` waves through anything inside the seat and the chip is inside the seat. The check written for this bug could not see the second half of it. A control inside a seat is a rival for the tap now, and against the old stylesheet it fails seven scenarios. 982 unit tests, 6 of them new; the layout sweep is 320 audits with the one pre-existing failure (a $1,300 chip behind seat 11 on a twelve-handed 360x640 all-in). Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
120 lines
6.9 KiB
JavaScript
120 lines
6.9 KiB
JavaScript
// The seat holding the dealer button has to still be a seat you can tap.
|
|
//
|
|
// On a fresh table the button is on seat 1, and seat 1 is where the first player goes.
|
|
// Measured on a 390px phone, a 72x46 puck: the dealer chip's invisible tap pad ran from
|
|
// the puck's own centre to its right edge and from its top to 3px off its bottom. The
|
|
// chip's click handler calls stopPropagation, so every tap that landed there reached
|
|
// nothing at all — the seat holding the button kept 52% of its own face, 130 of 276
|
|
// sampled points going to a chip whose only answer was "give the button to the seat
|
|
// that has it". Seating the first player, on the seat a dealer reaches for first, did
|
|
// nothing. Every other seat kept 97%, and the button moves every hand, so the dead puck
|
|
// moved around the table with it.
|
|
//
|
|
// Three things had to agree for that to happen and they are the three checked here: the
|
|
// pad exists, the chip under it is live, and its handler eats the event. The pad is gone
|
|
// (nothing needs it — no finger can reach any chip), the chip that has the button is
|
|
// inert to the pointer, and the handler bails rather than swallowing a tap it has no
|
|
// answer for.
|
|
const test = require("node:test");
|
|
const { describe } = require("node:test");
|
|
const assert = require("node:assert");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const src = (...parts) => fs.readFileSync(path.join(__dirname, "..", "..", "JoshHeaps.Net", ...parts), "utf8");
|
|
|
|
const CSS = src("wwwroot", "css", "casino", "holdem.css");
|
|
const RENDER = src("wwwroot", "js", "casino", "holdem", "render.js");
|
|
const MAIN = src("wwwroot", "js", "casino", "holdem", "main.js");
|
|
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
|
|
|
|
// The body of every rule written at exactly this selector, concatenated. Every one of
|
|
// them, because the phone block redeclared the chip's pad under the same name and it
|
|
// was the redeclared copy — 44px centred on the disc — that reached furthest in.
|
|
const rulesFor = selector => {
|
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const re = new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "gm");
|
|
const bodies = [];
|
|
let found;
|
|
while ((found = re.exec(CSS)) !== null) bodies.push(found[2]);
|
|
return bodies;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("the seat under the dealer button is still a seat", () => {
|
|
test("the chip has no invisible pad reaching back over its own puck", () => {
|
|
// Not "a smaller pad". A fixed pad cannot be safe at every puck size: the seat
|
|
// search hands out pucks from 48px wide to 100-odd depending on the screen and
|
|
// the number of chairs, and any pad big enough to matter on the large one
|
|
// covers the middle of the small one.
|
|
assert.equal(rulesFor(".dealerChip::after").length, 0,
|
|
"the dealer chip grew a tap pad again — see the note above .dealerChip in holdem.css");
|
|
});
|
|
|
|
test("the chip that has the button takes no taps", () => {
|
|
const bodies = rulesFor(".dealerChip.active");
|
|
assert.ok(bodies.length > 0, ".dealerChip.active has no rule at all");
|
|
assert.ok(bodies.some(body => /pointer-events:\s*none/.test(body)),
|
|
"the seat holding the button has a live control sitting on it");
|
|
});
|
|
|
|
test("the other chips stay inert until a pointer that can hover arrives", () => {
|
|
// This is what makes the pad unnecessary rather than merely unwanted. On touch
|
|
// there is no hover, so no ghost chip is reachable by a finger and the only live
|
|
// chip is the inert one above. Nothing a thumb can press is 22px.
|
|
const ghost = rulesFor(".dealerChip:not(.active)");
|
|
assert.ok(ghost.some(body => /pointer-events:\s*none/.test(body)),
|
|
"a chip nobody can see is taking taps");
|
|
|
|
const hover = CSS.slice(CSS.indexOf("@media (hover: hover)"));
|
|
const revealed = hover.match(/\.seat:hover\s+\.dealerChip:not\(\.active\)\s*\{([^}]*)\}/);
|
|
assert.ok(revealed, "hovering a seat no longer offers to put the button there");
|
|
assert.match(revealed[1], /pointer-events:\s*auto/,
|
|
"the mouse path to moving the button is gone");
|
|
});
|
|
|
|
test("and the handler does not swallow a tap it has no answer for", () => {
|
|
// stopPropagation is what turned a pointless control into a hole. It still runs
|
|
// for the eight seats the button could move to; the ninth returns first.
|
|
const handler = RENDER.slice(RENDER.indexOf('querySelector(".dealerChip").addEventListener'));
|
|
const body = handler.slice(0, handler.indexOf("});"));
|
|
const guard = body.indexOf("dealerSeatIndex");
|
|
const stop = body.indexOf("stopPropagation");
|
|
assert.ok(guard >= 0, "the chip's click handler does not check whose button it is");
|
|
assert.ok(stop >= 0, "the chip stopped guarding the seat behind it from the other chips");
|
|
assert.ok(guard < stop,
|
|
"the guard has to come first, or the tap is eaten before anyone decides it meant nothing");
|
|
});
|
|
|
|
test("moving the button to where it already is is not a move", () => {
|
|
// The keyboard can still reach the chip, and the same no-op would take the
|
|
// blinds off the felt and re-post the same chips to announce that nothing
|
|
// happened. The seat menu has always hidden "Give the button" on the seat that
|
|
// holds it; this is the same rule where the money is.
|
|
const from = MAIN.indexOf("makeDealer: index =>");
|
|
assert.ok(from >= 0, "makeDealer is gone or renamed");
|
|
const body = MAIN.slice(from, MAIN.indexOf("blindsArePosted", from));
|
|
assert.match(body, /if\s*\(\s*index\s*===\s*state\.dealerSeatIndex\s*\)\s*return/,
|
|
"makeDealer will re-post the blinds to move the button nowhere");
|
|
});
|
|
});
|
|
|
|
describe("the sweep can see this kind of failure now", () => {
|
|
test("a control inside a seat counts as a rival for the seat's tap", () => {
|
|
// The layout sweep already had a check named "a seat's centre belongs to that
|
|
// seat", written after the same chip's pad grew *outward* and started taking
|
|
// taps from the seat next door. It ran clean through all of this: it waved
|
|
// through anything the seat contained, and the chip is inside the seat. The
|
|
// check that existed for this exact bug could not see the second half of it.
|
|
const from = AUDIT.indexOf("seatOwnership()");
|
|
assert.ok(from >= 0, "the seat-ownership check is gone");
|
|
const body = AUDIT.slice(from, AUDIT.indexOf("oneDealerButton()", from));
|
|
|
|
assert.match(body, /seat\.contains\(rival\)/,
|
|
"a seat's own control is being waved through again");
|
|
assert.doesNotMatch(body, /if\s*\(\s*!hit\s*\|\|\s*seat\.contains\(hit\)/,
|
|
"the early return that hid this is back: it exempts every descendant, chip included");
|
|
});
|
|
});
|