// 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"); }); });