The chips were the last thing on the table nobody had measured, and the sweep had never seen one: it audited a table that had never had a bet placed on it, so a third of what is on the felt during a hand went unlooked at for the tool's whole life. It posts blinds, deals and calls three times now before it measures anything. What that found, and what this fixes: - Chips accumulated. Seats are kept and updated in place rather than rebuilt, so clearing the bets layer stopped clearing the ones anchored inside a seat and nothing else ever did. An iPhone 14 had one chip per seat after the deal and five by the third render; a dealer's every tap left another disc on the felt. - The ring-or-cling decision asked how wide the window was. A phone held sideways is 844px across and gives the felt 117px: it passed the width test, took the ring, and every chip on it was painted under a puck — the seats carry z-index 1 and the bets layer carries none, so a chip that lands under one is gone, and with it the only number saying what that player has in. Measured now, and only measured. - Getting clear of a box was `edgeReach(puck) + edgeReach(chip)`, which is right only straight up, down or sideways. Two axis-aligned boxes are clear when they are apart on one axis, so the travel is the reach of the box you get by adding their widths and their heights. At 768x1024 a $10 leaving a seat on the diagonal wanted 88px and got 77, and came to rest 11px inside its own player's puck — every seat off the four compass points, which is most of them. - Every chip was laid out at the first chip's size. A chip is as wide as the number on it: "$5" is 21px and "$10" is 27px on a phone, so each $10 was placed as if it were 6px narrower than it is. The widest is no better: at 768x1024 one 47px chip made the whole table give up a ring the other four had room for. - Anchored chips settled where they landed. At 640x360 the felt is 528x83 and a puck is 55 of those pixels tall: clearing it takes 41px, the players facing each other are 70px apart, and both chips set off 41px into a gap that is 70 wide. Three overlapping pairs on a twelve-handed table. Each chip now gives ground only to something it is actually standing on, a pixel at a time — a midline rule or a single scale factor both punish the seats at the ends of a rail, which were never going to hit anything. - Nothing knew the pot total was there. Seat 1's $10 was sitting on "Pot $65". Two things off the felt, from the same sweep: - The toast outlived its news. "Next hand — button on seat 2. [Undo]" stayed up while the hand was dealt and three players called: an offer to move the button, wired to a snapshot that predates the hand, so taking it would have thrown the hand away without saying so. It was also in the way of thirty small screens' seats and controls. The bar drops it as it takes the next action; the bar's own Undo names what it would actually take back. - The pot dialog cut a name off. Three players in the pot on a Galaxy S8, two rows visible, the third below a hairline that reads as the end of the list — a dealer ticking the two they could see would have paid two thirds of a three-way pot. The 85vh budget was still subtracting the action bar's height from a dialog that has sat above the bar since its z-index was fixed, and a player's ceiling moves beside their name on a short screen. All three fit now, and where a list does run on, it runs on under a fade rather than stopping dead on a row boundary. The pot badge goes on a phone held sideways: 86x66 of a 692x113 felt, for a number the action bar prints three inches below it, standing in the only band a bet chip has. The audit stopped calling one thing another. An element clipped away by a panel that scrolls is not covered by whatever is painted at that height — the wizard's third winner came back for four viewports as "fully covered by button Confirm Winner(s)". 190 audits: 25 failing configurations to 7, and every one of those seven is the seat ring hanging past the rail on a landscape phone, which is the known residual from the last round. 663 unit tests, 44 of them new. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
194 lines
9.2 KiB
JavaScript
194 lines
9.2 KiB
JavaScript
// 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 TABLES = [
|
|
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
|
["Bob", 250], ["Dee", 600]]],
|
|
["packed", 12, [["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]]],
|
|
];
|
|
|
|
function seedTable([seats, players]) {
|
|
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;
|
|
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] of TABLES) {
|
|
const label = `${rawLabel} · ${shape} · ${hand}`;
|
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
|
await page.evaluate(seedTable, [seats, players]);
|
|
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);
|
|
for (let i = 0; i < 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);
|
|
}
|
|
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 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();
|