// Drives uiAudit.js over the dealer's surfaces at every viewport worth caring about. // // node tests/holdem/runUiAudit.js [baseUrl] // // Needs Playwright available (`npx playwright install chromium` once). It is // deliberately NOT wired into the unit-test suite or CI, because that suite is // dependency-free and runs in a second — this one needs a browser and a running // site. Run it after any change that adds or resizes a control. // // No Playwright to hand? Open the page, paste tests/holdem/uiAudit.js into devtools, // and call LayoutAudit.runAll(). Same checks, one viewport. const path = require("path"); const BASE = process.argv[2] || "http://localhost:5299"; const AUDIT = path.join(__dirname, "uiAudit.js"); // Portrait phones, tablets, desktops, then the same phones on their side — landscape // is where this layout has broken most often, because the table is sized from width // and the action bar is fixed to a very short viewport. const VIEWPORTS = [ [360, 640, "Galaxy S8"], [375, 667, "iPhone SE"], [390, 844, "iPhone 14"], [412, 915, "Pixel 7"], [430, 932, "iPhone Pro Max"], [768, 1024, "iPad"], [834, 1112, "iPad Air"], [1024, 1366, "iPad Pro"], [1280, 900, "desktop"], [1600, 1000, "wide desktop"], [640, 360, "S8 landscape"], [667, 375, "SE landscape"], [844, 390, "iPhone landscape"], ]; const OVERLAYS = [ ["rulings", () => document.getElementById("openRulingsBtn").click()], ["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)], ["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }], ["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})], ["pot wizard", () => document.getElementById("resolvePotBtn").click()], ]; // A table part-way through a real night: long names, uneven stacks, a hand log. // // Two of them. The sweep ran on one table shape for its whole life — five players at a // nine-handed table — and how much room the felt has depends on how many people are // sitting down more than on anything a viewport can tell you. A full table is where the // seats crowd, and it was never once looked at: measured when it finally was, seventeen // of fifty-four tables had one player's name or stack painted under another. const PACKED = [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480], ["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]; const TABLES = [ ["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]], false], ["packed", 12, PACKED], // The same twelve, every one of them all-in for a different amount. This is the // shape that makes a pot layer per player, and the sweep had never reached it: // every table here was driven with Call and Check, so the side-pot breakdown — the // one thing on the bar whose height depends on the table — was empty at all 294 // audits. Measured when it finally was: the bar took 411px of a 640px phone and // left 96px of felt for twelve seats. ["all in", 12, PACKED, false, 12], // The half-full table again, with the board turned on and five cards on it. The // board is furniture in the middle of the felt that nothing else was ever measured // against — and where the middle cannot hold it, it moves to a strip above the // table and takes the felt's height with it, which is the seats' problem too. // // Deliberately the same five players in the same nine chairs as the shape above, so // the only thing that differs between them is the board. A seventh player would have // told us that a seven-handed table crowds, which is true, already known, and not // what this row is here to find out. ["board", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]], true], ]; function seedTable([seats, players, board]) { const state = HoldemState.createDefault(); HoldemState.resizeSeats(state, seats); players.forEach(([name, cash], i) => { state.seats[i].name = name; state.seats[i].inHand = true; state.seats[i].buyIns = cash; state.seats[i].stack = cash; }); state.smallBlind = 5; state.bigBlind = 10; for (let hand = 1; hand <= 4; hand++) { state.handHistory.push({ handNumber: hand, timestamp: Date.now(), pot: 120, payouts: [{ id: state.seats[hand % players.length].id, label: "P", amount: 120 }], ledger: state.seats.slice(0, players.length) .map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })), }); } state.handNumber = 5; if (board) { state.trackBoard = true; state.board = ["KD", "9C", "4S", "AH", "2H"]; } HoldemState.save(state); } async function main() { let chromium; try { ({ chromium } = require("playwright")); } catch { console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium"); console.error("Or paste tests/holdem/uiAudit.js into devtools and call LayoutAudit.runAll()."); process.exit(2); } const browser = await chromium.launch(); const failures = []; const warnings = []; let checks = 0; // Twice over: once with a mouse, once with a thumb. The 44px rule stands itself // down where `hover: hover` says there's a pointer, and a headless desktop browser // is a pointer — so a single pass here has never once measured a touch target, // however many viewports it visited. The phone-sized ones are exactly the pass that // needs it. for (const touch of [false, true]) { const context = await browser.newContext(touch ? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 } : {}); const page = await context.newPage(); const hand = touch ? "touch" : "mouse"; for (const [width, height, rawLabel] of VIEWPORTS) { await page.setViewportSize({ width, height }); for (const [shape, seats, players, board, shoves] of TABLES) { const label = `${rawLabel} · ${shape} · ${hand}`; await page.goto(`${BASE}/Casino/TexasHoldem`); await page.evaluate(seedTable, [seats, players, board]); await page.goto(`${BASE}/Casino/TexasHoldem`); await page.click("#nextHandBtn"); await page.waitForTimeout(300); // Deal it, and get chips in front of a few players. The sweep used to // audit a table that had never had a bet on it, so the bets layer was // empty at every viewport it visited and nothing it drew was ever looked // at — which is a third of what is on the felt during a hand. await page.evaluate(() => { const deal = [...document.querySelectorAll("#actionBar button")] .find(b => /post blinds/i.test(b.textContent)); if (deal) deal.click(); }); await page.waitForTimeout(220); // Everyone's whole stack in, which is what stacks up the side pots. // There is no one-tap shove: a player who can't cover the bet gets // "Call all-in", and anyone else has to open the raise pad, take its // All-in preset and confirm. for (let i = 0; i < (shoves || 0); i++) { const step = await page.evaluate(() => { const find = re => [...document.querySelectorAll("#actionBar button")] .find(b => !b.disabled && re.test(b.textContent.trim())); const short = find(/^Call all-in/); if (short) { short.click(); return "done"; } const open = find(/^Raise|^Bet/); if (!open) return null; open.click(); return "pad"; }); if (!step) break; await page.waitForTimeout(120); if (step === "pad") { await page.evaluate(() => { const find = re => [...document.querySelectorAll("#actionBar button")] .find(b => !b.disabled && re.test(b.textContent.trim())); const max = find(/^All-in/); if (max) max.click(); const go = find(/^Raise to|^Bet/); if (go) go.click(); }); await page.waitForTimeout(120); } } for (let i = 0; i < (shoves ? 0 : 3); i++) { const acted = await page.evaluate(() => { const btn = [...document.querySelectorAll("#actionBar button")] .find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled); if (!btn) return false; btn.click(); return true; }); if (!acted) break; await page.waitForTimeout(120); } if (board) { await page.evaluate(() => { const s = HoldemState.load(); s.street = "river"; s.board = ["KD", "9C", "4S", "AH", "2H"]; s.seats.filter(p => p.inHand).forEach((p, i) => { p.cards = [["AS", "KC"], ["AC", "3D"], ["9H", "9S"], ["7C", "5D"], ["JD", "TS"]][i] || ["", ""]; }); HoldemState.save(s); }); await page.reload(); await page.waitForTimeout(300); } await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; })); await page.addScriptTag({ path: AUDIT }); const table = await page.evaluate(() => LayoutAudit.runAll()); checks++; if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) }); table.warnings.forEach(w => warnings.push(`${label}: ${w}`)); // The raise pad, which is part of the bar rather than an overlay and so // was never once measured by any of this. It is the tallest thing the // bar ever holds and the only place in the tool a dealer types an // amount, and it had a 36px-tall field on every phone in the list: the // `hover: none` rule that sizes fields for a thumb names the settings // panels and the seat form, and the one field on the thumb surface was // not in the list. Nothing here could see that, because nothing here // ever opened it. if (await page.evaluate(() => { const btn = [...document.querySelectorAll("#actionBar button")] .find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled); if (!btn) return false; btn.click(); return true; })) { await page.waitForTimeout(200); checks++; const pad = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "#actionBar button, #actionBar input", })); if (!pad.clean) { failures.push({ where: `raise pad · ${label} ${width}x${height}`, offenders: pad.failed.flatMap(f => f.offenders), }); } await page.evaluate(() => { const cancel = [...document.querySelectorAll("#actionBar button")] .find(b => /^Cancel/.test(b.textContent.trim())); if (cancel) cancel.click(); }); await page.waitForTimeout(150); } // The overlays don't change with the table's shape, so they are only // opened over the half-full one — twice the viewports is enough time // spent already. if (shape !== TABLES[0][0]) continue; for (const [name, open] of OVERLAYS) { await page.evaluate(open); await page.waitForTimeout(180); checks++; const result = await page.evaluate(() => { const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden); if (!overlay) return { offenders: [] }; return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`); }); await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; })); if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders }); } } } for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) { const label = `${rawLabel} · ${hand}`; await page.setViewportSize({ width, height }); await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`); await page.evaluate(() => { const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent)); if (button) button.click(); }); await page.addScriptTag({ path: AUDIT }); checks++; const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" })); if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) }); result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`)); } await context.close(); } await browser.close(); console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`); warnings.forEach(w => console.log(` note: ${w}`)); if (failures.length === 0) { console.log("clean"); return; } failures.forEach(f => { console.log(`\nFAIL ${f.where}`); f.offenders.forEach(o => console.log(` ${o}`)); }); process.exit(1); } main();